diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 522416bf..a6f71a74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,16 @@ jobs: - name: container workspace: "@example/computer-container" path: examples/container + - name: agent + workspace: "@example/computer-agent" + path: examples/agent + # Checks in a hand-written env shape rather than the + # generated file, which also inlines the whole workerd type + # library. `wrangler types` refuses to overwrite a file it + # did not write, so the generate step is skipped below — + # which also means this example's typecheck runs against + # the file the repository actually ships. + worker_types: hand-written steps: - uses: actions/checkout@v6 with: @@ -127,6 +137,7 @@ jobs: - run: npm run build --workspaces --if-present - name: Generate worker types + if: matrix.worker_types != 'hand-written' run: npx wrangler types working-directory: ${{ matrix.path }} @@ -136,8 +147,8 @@ jobs: - name: Typecheck run: npm run typecheck --workspace ${{ matrix.workspace }} --if-present - # Examples don't ship tests today; --if-present makes this a - # no-op until they do. + # Only some examples ship tests; --if-present keeps this a no-op + # for the ones that don't. - name: Test run: npm test --workspace ${{ matrix.workspace }} --if-present diff --git a/AGENTS.md b/AGENTS.md index e8130118..596b76ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,10 @@ loop. Reach for these when you're chasing a behavior the unit tests don't cover. - `computerd-fuse-flush.mjs` end-to-end checks that the FUSE driver spills its in-memory write buffer into the backing VFS, so a capnweb-side `pullOnce` actually sees the bytes. +- `container-mount-probe.sh` asks whether a container can give one command a + read-only view of the mount point, which decides whether `writable: false` + can be enforced preventively there rather than refused on write-back. Run + it inside a deployed container; local Docker is more permissive. - `fs-tests.sh` / `run-fs-tests.sh` run the filesystem conformance harness against the FUSE mount. - `fs-bench.sh` / `run-fs-bench.sh` benchmark common development diff --git a/README.md b/README.md index d34b0984..62755b1b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ public surface. Each is a Worker workspace with its own README. - [`examples/worker-javascript`](examples/worker-javascript) — mirrors `worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic Worker instead of running a shell command. +- [`examples/agent`](examples/agent) — an agent over all three backends + that asks a human before any command whose effect it cannot read off + the command's own text, and runs everything else without write + access. - [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) chat agent that uses the workspace as its working directory, reachable from a terminal. diff --git a/docs/20_approval.md b/docs/20_approval.md index b7e9fab4..4a5ac5e8 100644 --- a/docs/20_approval.md +++ b/docs/20_approval.md @@ -145,6 +145,12 @@ Ask before the command starts. Once it is running there is nowhere to suspend it that does not risk a partial result, and a write-by-write prompt would ask hundreds of times for one `rm -rf`. +That puts the question at the tool layer rather than at the gate, since +the tool layer is the only one of the two that runs before the action +exists. [`examples/agent`](../examples/agent) wires it that way and is +worth reading for how the two seams divide the work: the tool layer +asks, and the gate — which cannot ask — narrows. + ## The audit hook Notified after an action has been decided, and after it has run. @@ -216,3 +222,7 @@ error, so the agent loop survives it. which are a fixed property of a path rather than a per-command decision. Both apply, and neither is a way around the other. - [09. Tool interface](./09_tool_interface.md) — the agent-facing tools. +- [`examples/agent`](../examples/agent) — all three used together, with + an approval matcher whose only job is to ask fewer questions and a + test that runs every command it allows to check that none of them + write. diff --git a/examples/agent/.gitignore b/examples/agent/.gitignore new file mode 100644 index 00000000..55845e82 --- /dev/null +++ b/examples/agent/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +build/ +node_modules/ diff --git a/examples/agent/Dockerfile b/examples/agent/Dockerfile new file mode 100644 index 00000000..b3780c0b --- /dev/null +++ b/examples/agent/Dockerfile @@ -0,0 +1,43 @@ +# Container image for the agent example. +# +# Pulls the computerd binary out of the public GHCR image. That image is +# a single layer over `scratch` whose only contents are the SEA +# binary at /usr/local/bin/computerd; we COPY it into a slim debian +# runtime below. The :VERSION tag is rewritten in lockstep with +# the rest of the monorepo by script/set-versions.mjs. +# +# computerd mounts a FUSE filesystem at MOUNT_POINT so exec'd commands +# see the same VFS the RPC surface reads and writes. With +# FUSE_MOUNT=auto (below) the same image works in both directions: +# Cloudflare Containers expose /dev/fuse to the workload, so the +# real FUSE backend mounts; `wrangler dev` doesn't, so computerd falls +# back to the userspace shim transparently. + + +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl gnupg git \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd + +# computerd's defaults: HTTP+WS on :8080, FUSE mount on MOUNT_POINT. +# FUSE_MOUNT=auto picks real FUSE on Cloudflare Containers (where +# /dev/fuse is exposed) and the userspace shim under wrangler dev. +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/computerd"] diff --git a/examples/agent/README.md b/examples/agent/README.md new file mode 100644 index 00000000..73079ec7 --- /dev/null +++ b/examples/agent/README.md @@ -0,0 +1,228 @@ +# agent example + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. + +An agent that runs shell commands in a Workspace and asks before the +ones it cannot vouch for. It is here to show what +[`docs/20_approval.md`](../../docs/20_approval.md) is for: the write +capability, the gate, and the audit hook, with something using all +three. + +The property the example is built around: + +> a command runs with write access **if and only if** a human approved +> it. + +## Why that is one property and not two + +There are two obvious ways to keep a model from wrecking a workspace, +and only one of them is a boundary. + +The first is to read the command and decide. That is a heuristic. It +was also the only tool available before the write capability existed, +and it was load-bearing, which is why its holes mattered: an early +version of the matcher in `src/approval-policy.ts` waved through `find +/workspace -mindepth 1 -delete`, because the verb was on the allowlist +and its flags were not. + +The second is to withhold the capability. A command that was not +approved runs against a filesystem handle that has no write access, so +its writes fail whatever anybody believed about them. That is not a +guess, and it covers every caller rather than the model's path only. + +Putting the second one underneath is what makes the first one safe to +keep. The matcher's job shrinks from *stopping* damage to *reducing +interruptions*, and its failure modes stop being symmetrical: + +| The matcher is wrong about | What it costs | +|---|---| +| a read, calling it a write | one question nobody needed to answer | +| a write, calling it a read | the command runs read-only and fails visibly | + +Neither loses a file. That is the only reason a regex-and-allowlist +matcher belongs anywhere near this decision, and it is why the two +decisions are wired to a single predicate in `src/agent.ts`: + +```ts +writable: (input) => decideApproval(input, policy).needsApproval +``` + +which reads backwards until you notice when it runs. The AI SDK does +not call a tool's `execute` until approval has been granted, so asking +for write access exactly when approval was required means write access +and human attention cannot drift apart. + +## The three backends, and why the answer differs + +One Workspace, three backends, because a withheld capability is +enforced in a different place in each and that difference is worth +seeing. + +| Backend | Runs | Refusal lands | Default rule | +|---|---|---|---| +| `worker-shell` | just-bash in a Dynamic Worker | inside the command, as `EROFS` | matcher | +| `worker-javascript` | an ECMAScript module in a Dynamic Worker | inside the module, the same way | always ask | +| `container-shell` | computerd over real coreutils | on write-back, as skipped entries | always ask | + +Only `worker-shell` gets the matcher. The container runs real binaries +with public network access, and it is also where a refused write is +caught late — the command writes to the container's own copy of the +tree and the refusal arrives when those changes are pulled back — so it +is the worst place to be guessing. The JavaScript backend evaluates a +module whose effects are not a function of any verb, so there is +nothing for a matcher to be conservative about. Both are gated +outright. + +## Architecture + +``` +you ──► cli/chat.mjs ──► Worker /c//agent + ▲ (@ai-sdk/tui) │ + │ ▼ + └── approval prompt ── AgentExample DO + │ streamText + toolApproval + │ + Workspace ├─ gate (narrows write access) + ├─ audit (records the outcome) + │ + ├─► WorkerShellBackend ──► Dynamic Worker + ├─► WorkerJavaScriptBackend ──► Dynamic Worker + └─► CloudflareContainerBackend ──► computerd +``` + +The gate and the audit hook are installed on the `Workspace`, not +around the agent. That is deliberate: the tool layer only covers the +model's path, while the seams also see the HTTP routes below and +anything added later. + +The gate is not a second copy of the approval decision, and it cannot +be — a gate runs once the action exists, and there is nowhere to +suspend a running command that does not risk a partial result. What it +does is check the invariant from the other side. A command holding +write access should be one the matcher would have raised a question +about, since that is the only route to write access through the tool +layer. A recognized read that turns up wanting write access did not +come that way, and it is narrowed back to read-only, which costs it +nothing the matcher says it needed. + +## The approval has to survive the trip + +The conversation lives in the terminal, not in the Durable Object. An +answer to an approval therefore arrives as a claim the client makes +about something you supposedly did, and on that claim rests the write +access the command is about to get. So the worker signs every approval +it asks for, with a per-object key it keeps in storage, and the AI SDK +checks the signature before it will run the tool call. An approval that +was never issued has nothing to present. + +The terminal UI drops the signature. Recording your answer replaces the +approval rather than adding to it, so what goes back is unsigned and +the turn dies with `missing signature`. That is true of every published +`@ai-sdk/tui` through 1.0.52, so the client wraps its transport to +remember the signatures it saw and put them back: +[`cli/approval-signatures.mjs`](cli/approval-signatures.mjs). The +repair belongs in the transport because a signature is not a secret — +it is a MAC only the worker can produce or check — and carrying one +across a turn it was always meant to survive gives the client nothing +it did not already have. + +## Running it + +```bash +npm install +npm run build # from the repo root +npm run dev --workspace @example/computer-agent # needs Docker for the container backend +``` + +`wrangler dev` builds the container image before it will start, so a +machine that cannot build it gets none of the example, including the +two backends that never touch a container. There is a second config +without the container for exactly that case: + +```bash +npm run dev:local --workspace @example/computer-agent +``` + +Everything below works the same way under it, except that asking for +the `container-shell` backend fails: it is not there. + +Then, in another terminal, put something in the workspace for the agent +to look at and start talking to it: + +```bash +curl -X PUT --data-binary 'hello world' \ + localhost:8787/c/default/file/workspace/hello.txt + +npm run chat --workspace @example/computer-agent +``` + +That `PUT` is itself a gated action — it goes through `Workspace.fs`, +so it shows up in the audit trail below as `fs.write`. + +Two things to try, in this order: + +``` +cat the file at /workspace/hello.txt +``` + +Runs unattended. The matcher recognizes it, and it ran without write +access, which cost it nothing. + +``` +delete everything under /workspace +``` + +Stops and asks. Say no and nothing happens. Say yes and it runs with +the write access approval bought it. + +The approval prompt shows the tool and the command. It does not show +the matcher's reason for asking, because an approval request has +nowhere to carry per-call text; the reason goes to the audit trail +instead: + +```bash +curl -s localhost:8787/c/default/audit | jq +``` + +The HTTP surface, if you would rather drive it without a model: + +``` +PUT /c//file/workspace/ write a file +GET /c//file/workspace/ read a file +POST /c//exec run a command {"command":…,"backend":…} +POST /c//agent one agent turn (UI message stream) +GET /c//audit what the audit hook recorded +``` + +`POST /c//exec` is a caller the tool layer knows nothing about, +which makes it the quickest way to watch the gate work: ask it to run +`cat` and the audit trail shows the command allowed with `writable: +false`, because the gate took away access the command never needed. + +## Tests + +```bash +npm test --workspace @example/computer-agent +``` + +Three files, and the third is the interesting one. + +`approval-policy.test.ts` pins what the matcher says. `agent.test.ts` +pins the invariant — that write access and approval are the same +decision — and the gate's narrowing. + +`approval-policy.effects.test.ts` does something the other two cannot. +Assertions about a matcher are written by whoever wrote the matcher, +from the same blind spot, so they find the cases somebody thought of. +That file instead runs every command the policy would allow through +just-bash itself, against a filesystem that records every mutation, +and fails if any of them wrote — 630 commands generated, 475 allowed +unattended, none of them writing. Both real defects in this policy were +found by running the agent by hand and noticing, not by listing +examples, which is the argument for having it. + +Its corpus derives the verbs from the allowlist rather than from a copy, +so a verb added to the policy later comes under test without anybody +remembering to add it. diff --git a/examples/agent/cli/approval-signatures.mjs b/examples/agent/cli/approval-signatures.mjs new file mode 100644 index 00000000..cb04007d --- /dev/null +++ b/examples/agent/cli/approval-signatures.mjs @@ -0,0 +1,78 @@ +/** + * A chat transport that remembers the signatures on the approvals it + * saw, and puts them back on the answers it sends. + * + * The worker signs every approval it asks for, because the conversation + * lives in this process: an answer arrives as a claim the client makes + * about something you supposedly did, and the signature is what makes + * that claim checkable. The AI SDK verifies it before it will run the + * tool call. + * + * The terminal UI drops it. Recording an answer replaces the whole + * approval object rather than adding to it: + * + * part.approval = { id: request.approvalId, approved, ...reason }; + * + * so the signature the worker issued is gone by the time the answer is + * posted back, and the turn dies with + * `AI_InvalidToolApprovalSignatureError: missing signature`. That is + * true of every published @ai-sdk/tui through 1.0.52. + * + * A signature is not a secret — it is a MAC the worker issued over an + * approval id, and only the worker can make or check one. Carrying it + * across a turn it was always meant to survive grants this client + * nothing it did not already have, which is why the repair belongs + * here, in the transport, rather than in a fork of the UI. The cache + * only ever supplies a signature the worker itself sent for that exact + * approval id, so a forged approval still has nothing to present. + */ + +/** + * Wrap a chat transport so approval signatures survive the round trip. + * + * @template {{ sendMessages: (options: any) => Promise> }} T + * @param {T} transport + * @returns {T} + */ +export function withApprovalSignatures(transport) { + /** @type {Map} */ + const signatures = new Map(); + + return Object.create(transport, { + sendMessages: { + value: async (options) => { + for (const message of options.messages ?? []) { + for (const part of message.parts ?? []) restore(part, signatures); + } + const stream = await transport.sendMessages(options); + return stream.pipeThrough(remember(signatures)); + }, + }, + }); +} + +/** + * Re-attach the signature for an answered approval, if we have one and + * the answer is missing it. + */ +function restore(part, signatures) { + const approval = part?.approval; + if (!approval || approval.signature !== undefined) return; + const signature = signatures.get(approval.id); + if (signature !== undefined) approval.signature = signature; +} + +/** + * Note the signature on every approval the worker asks for, passing the + * stream through untouched. + */ +function remember(signatures) { + return new TransformStream({ + transform(chunk, controller) { + if (chunk?.type === "tool-approval-request" && chunk.signature !== undefined) { + signatures.set(chunk.approvalId, chunk.signature); + } + controller.enqueue(chunk); + }, + }); +} diff --git a/examples/agent/cli/approval-signatures.test.mjs b/examples/agent/cli/approval-signatures.test.mjs new file mode 100644 index 00000000..2d677650 --- /dev/null +++ b/examples/agent/cli/approval-signatures.test.mjs @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { withApprovalSignatures } from "./approval-signatures.mjs"; + +function streamOf(chunks) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); +} + +async function drain(stream) { + const reader = stream.getReader(); + const out = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) return out; + out.push(value); + } +} + +// Stands in for the worker: hands back whatever chunks the test names +// and remembers the messages it was asked to send. +function fakeTransport(chunks = []) { + const transport = { + sent: [], + async sendMessages(options) { + transport.sent.push(options); + return streamOf(chunks); + }, + async reconnectToStream() { + return null; + }, + }; + return transport; +} + +const request = { + type: "tool-approval-request", + approvalId: "aitxt-1", + toolCallId: "call-1", + signature: "sig-1", +}; + +// The exact mutation @ai-sdk/tui performs on a "yes": the whole +// approval object is replaced, so the signature the worker issued does +// not survive. See applyToolApprovalResponse in @ai-sdk/tui. +function answerLikeTheTUI(part, approved) { + part.state = "approval-responded"; + part.approval = { id: part.approval.id, approved }; +} + +function respondedMessage(approved = true) { + const part = { + type: "tool-exec", + toolCallId: "call-1", + state: "approval-requested", + input: { command: "rm /workspace/x" }, + approval: { id: "aitxt-1", signature: "sig-1" }, + }; + answerLikeTheTUI(part, approved); + return { id: "m1", role: "assistant", parts: [part] }; +} + +describe("withApprovalSignatures", () => { + it("passes the worker's chunks through untouched", async () => { + const inner = fakeTransport([{ type: "start" }, request]); + const stream = await withApprovalSignatures(inner).sendMessages({ messages: [] }); + expect(await drain(stream)).toEqual([{ type: "start" }, request]); + }); + + it("puts back the signature the terminal UI dropped", async () => { + const inner = fakeTransport([request]); + const transport = withApprovalSignatures(inner); + + // Turn one: the worker asks, and the signature goes by on the wire. + await drain(await transport.sendMessages({ messages: [] })); + + // Turn two: the answer comes back without it. + await transport.sendMessages({ messages: [respondedMessage(true)] }); + + const part = inner.sent[1].messages[0].parts[0]; + expect(part.approval).toEqual({ id: "aitxt-1", approved: true, signature: "sig-1" }); + }); + + it("signs a refusal too, so a no is as checkable as a yes", async () => { + const inner = fakeTransport([request]); + const transport = withApprovalSignatures(inner); + await drain(await transport.sendMessages({ messages: [] })); + + await transport.sendMessages({ messages: [respondedMessage(false)] }); + + const part = inner.sent[1].messages[0].parts[0]; + expect(part.approval).toMatchObject({ approved: false, signature: "sig-1" }); + }); + + it("leaves an approval alone when it never saw a signature for it", async () => { + const inner = fakeTransport([]); + const transport = withApprovalSignatures(inner); + + await transport.sendMessages({ messages: [respondedMessage(true)] }); + + const part = inner.sent[0].messages[0].parts[0]; + expect(part.approval).toEqual({ id: "aitxt-1", approved: true }); + }); + + it("does not overwrite a signature that survived", async () => { + const inner = fakeTransport([request]); + const transport = withApprovalSignatures(inner); + await drain(await transport.sendMessages({ messages: [] })); + + const message = respondedMessage(true); + message.parts[0].approval.signature = "sig-from-elsewhere"; + await transport.sendMessages({ messages: [message] }); + + expect(inner.sent[1].messages[0].parts[0].approval.signature).toBe("sig-from-elsewhere"); + }); + + it("keeps signatures apart when a turn asks about two commands", async () => { + const second = { ...request, approvalId: "aitxt-2", toolCallId: "call-2", signature: "sig-2" }; + const inner = fakeTransport([request, second]); + const transport = withApprovalSignatures(inner); + await drain(await transport.sendMessages({ messages: [] })); + + const message = respondedMessage(true); + const other = { + type: "tool-exec", + toolCallId: "call-2", + state: "approval-requested", + approval: { id: "aitxt-2", signature: "sig-2" }, + }; + answerLikeTheTUI(other, true); + message.parts.push(other); + await transport.sendMessages({ messages: [message] }); + + const parts = inner.sent[1].messages[0].parts; + expect(parts[0].approval.signature).toBe("sig-1"); + expect(parts[1].approval.signature).toBe("sig-2"); + }); + + it("delegates the rest of the transport", async () => { + const inner = fakeTransport([]); + expect(await withApprovalSignatures(inner).reconnectToStream({})).toBeNull(); + }); +}); diff --git a/examples/agent/cli/chat.mjs b/examples/agent/cli/chat.mjs new file mode 100755 index 00000000..94ce3f9e --- /dev/null +++ b/examples/agent/cli/chat.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/** + * chat [--worker URL] [--name NAME] [--title TITLE] + * + * A terminal chat window onto the agent, and the place you can watch + * an approval actually happen. Ask it to read something and it just + * answers; ask it to change something and the turn stops and waits for + * you. + * + * There is deliberately very little here. The AI SDK's terminal UI + * already knows how to render a pending approval and send the answer + * back, and the worker speaks the UI message stream that + * `DefaultChatTransport` posts to, so the client is a URL and one + * wrapper. + * + * The conversation lives in this process rather than on the server, + * which is why the worker signs its approval requests: an approval + * comes back as a claim this client makes about something you + * supposedly did, and the signature is what makes that claim checkable + * rather than merely plausible. The wrapper is there because the + * terminal UI throws that signature away when it records your answer; + * ./approval-signatures.mjs explains what it does about it. + * + * The worker has to be running first (`npm run dev`, default + * http://127.0.0.1:8787). Point somewhere else with --worker or the + * AGENT_WORKER env var. + * + * Two things worth trying, in this order: + * + * cat the file at /workspace/hello.txt + * Runs unattended. The matcher recognizes it, so it never asks — + * and it ran without write access, which cost it nothing. + * + * delete everything under /workspace + * Stops and asks. Say no and nothing happens. Say yes and it + * runs with the write access that approval bought it. Either way, + * `curl localhost:8787/c//audit` shows what the workspace + * recorded, including the answer you gave. + */ + +import { argv, env, exit, stderr } from "node:process"; +import { runAgentTUI } from "@ai-sdk/tui"; +import { DefaultChatTransport } from "ai"; +import { withApprovalSignatures } from "./approval-signatures.mjs"; + +const { workerUrl, name, title } = parseArgs(argv.slice(2)); + +let base; +try { + base = new URL(workerUrl); +} catch { + stderr.write(`invalid --worker URL: ${workerUrl}\n`); + exit(2); +} + +const api = new URL(`/c/${encodeURIComponent(name)}/agent`, base).toString(); + +stderr.write(`talking to ${api}\n`); + +await runAgentTUI({ + // The wrapper is not decoration: the terminal UI drops the signature + // off an approval when it records your answer, and the worker will + // not run an unsigned one. See ./approval-signatures.mjs. + transport: withApprovalSignatures(new DefaultChatTransport({ api })), + title: title ?? `computer-agent · ${name}`, +}); + +function parseArgs(args) { + let workerUrl = env.AGENT_WORKER ?? "http://127.0.0.1:8787"; + let name = env.AGENT_WORKSPACE ?? "default"; + let title; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--worker") { + workerUrl = args[++i] ?? workerUrl; + } else if (a === "--name") { + name = args[++i] ?? name; + } else if (a === "--title") { + title = args[++i] ?? title; + } else if (a === "-h" || a === "--help") { + stderr.write("usage: chat [--worker URL] [--name NAME] [--title TITLE]\n"); + exit(0); + } + } + return { workerUrl: workerUrl.replace(/\/+$/, ""), name, title }; +} diff --git a/examples/agent/package.json b/examples/agent/package.json new file mode 100644 index 00000000..8d62455d --- /dev/null +++ b/examples/agent/package.json @@ -0,0 +1,33 @@ +{ + "name": "@example/computer-agent", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example agent that runs shell commands through @cloudflare/computer, asking for approval only when a command's effect cannot be read off its text and running the rest without write access.", + "bin": { + "computer-agent-chat": "./cli/chat.mjs" + }, + "scripts": { + "dev": "wrangler dev", + "dev:local": "wrangler dev --config wrangler.local.jsonc", + "deploy": "wrangler deploy", + "chat": "node ./cli/chat.mjs", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "@ai-sdk/tui": "^1.0.43", + "@cloudflare/computer": "*", + "ai": "^7.0.0", + "workers-ai-provider": "^4.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "just-bash": "^3.0.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7", + "wrangler": "^4.107.1" + } +} diff --git a/examples/agent/src/agent.test.ts b/examples/agent/src/agent.test.ts new file mode 100644 index 00000000..a354bd9d --- /dev/null +++ b/examples/agent/src/agent.test.ts @@ -0,0 +1,320 @@ +import type { AuditOutcome, WorkspaceAction } from "@cloudflare/computer"; +import type { ExecWorkspaceLike } from "@cloudflare/computer/tools"; +import { describe, expect, it } from "vitest"; + +import { + createAgentTools, + createApprovalGate, + createAudit, + DEFAULT_BACKEND, + EXEC_BACKENDS, + execApproval, +} from "./agent.js"; +import { type ApprovalPolicy, DEFAULT_APPROVAL_POLICY } from "./approval-policy.js"; + +interface ExecCall { + command: string; + backend: string | undefined; + writable: boolean | undefined; +} + +/** + * Enough of a workspace to build the toolset against. `fs` is only + * here because createAITools reaches for it while wiring the read and + * list tools; nothing in this file calls it. + */ +function fakeWorkspace(): ExecWorkspaceLike & { calls: ExecCall[] } { + const calls: ExecCall[] = []; + return { + calls, + fs: {}, + runtime: { + async exec(command: string, options: { backend?: string; writable?: boolean }) { + calls.push({ command, backend: options.backend, writable: options.writable }); + return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; + }, + }, + } as unknown as ExecWorkspaceLike & { calls: ExecCall[] }; +} + +async function exec( + workspace: ExecWorkspaceLike & { calls: ExecCall[] }, + input: { command: string; backend?: string }, + policy?: ApprovalPolicy, +): Promise> { + const tools = createAgentTools({ + workspace: workspace as unknown as Parameters[0]["workspace"], + policy, + }); + const execute = tools.exec.execute as ( + i: typeof input, + o: unknown, + ) => Promise>; + return execute(input, {}); +} + +describe("the invariant", () => { + // The claim the whole example rests on: write access and human + // attention are the same decision, so neither can drift from the + // other without this failing. + // + // `execute` standing in for an approved call is exactly what the AI + // SDK does — it does not call `execute` until approval has been + // granted — so reaching this line at all means the human said yes. + it("gives write access to exactly the commands that need approval", async () => { + const commands = [ + "cat /workspace/a.txt", + "ls -la /workspace", + "find /workspace -name '*.ts'", + "rm -rf /workspace/sub", + "find /workspace -mindepth 1 -delete", + "npm install", + "echo hi > /workspace/out", + ]; + + for (const command of commands) { + const ws = fakeWorkspace(); + const result = await exec(ws, { command }); + const needsApproval = execApproval()({ command }) === "user-approval"; + + expect(ws.calls[0].writable, command).toBe(needsApproval); + // Reported back to the model too, so it can tell a refused write + // from a broken command. + expect(result.writable, command).toBe(needsApproval); + } + }); + + it("holds on every backend, not just the one with the matcher", async () => { + // The two gated backends approve everything, so everything that + // runs on them runs writable; worker-shell is the only one where + // the answer varies by command. + for (const backend of ["worker-javascript", "container-shell"]) { + const ws = fakeWorkspace(); + await exec(ws, { command: "cat /workspace/a.txt", backend }); + expect(ws.calls[0].writable, backend).toBe(true); + } + + const ws = fakeWorkspace(); + await exec(ws, { command: "cat /workspace/a.txt", backend: "worker-shell" }); + expect(ws.calls[0].writable).toBe(false); + }); + + it("tracks the policy rather than a copy of it", async () => { + // Flip the shell to "always" and the same read now costs a + // question, so it also gains write access. One decision, not two. + const policy: ApprovalPolicy = { rules: { "worker-shell": "always" }, fallback: "always" }; + const ws = fakeWorkspace(); + await exec(ws, { command: "cat /workspace/a.txt" }, policy); + expect(ws.calls[0].writable).toBe(true); + }); +}); + +describe("createAgentTools", () => { + it("offers no tool that writes except exec", () => { + // One door for mutation. A `write` or `edit` tool alongside the + // approval flow would let the model change files without any of it + // being consulted. + const tools = createAgentTools({ + workspace: fakeWorkspace() as unknown as Parameters[0]["workspace"], + }); + expect(Object.keys(tools).sort()).toEqual(["exec", "ls", "read"]); + }); + + it("describes every backend the workspace registers", () => { + expect(Object.keys(EXEC_BACKENDS).sort()).toEqual([ + "container-shell", + "worker-javascript", + "worker-shell", + ]); + expect(EXEC_BACKENDS).toHaveProperty(DEFAULT_BACKEND); + }); +}); + +describe("execApproval", () => { + it("asks about a command the matcher cannot vouch for", () => { + expect(execApproval()({ command: "rm -rf /workspace" })).toBe("user-approval"); + }); + + it("stays out of the way for a recognized read", () => { + expect(execApproval()({ command: "cat /workspace/a.txt" })).toBe("not-applicable"); + }); + + it("reads the default backend when the model names none", () => { + // The default is the one backend with a matcher, so omitting it has + // to mean that rather than falling through to the strict fallback. + expect(execApproval()({ command: "cat /workspace/a.txt" })).toBe("not-applicable"); + expect(execApproval()({ command: "cat /workspace/a.txt", backend: "container-shell" })).toBe( + "user-approval", + ); + }); +}); + +describe("createApprovalGate", () => { + const gate = createApprovalGate(); + + function check(action: WorkspaceAction) { + return gate.check(action); + } + + it("allows an action it was never asked about", () => { + // A kind added to the seam later should stay permitted rather than + // break a caller who was never consulted about it. + expect(check({ kind: "fs.write", path: "/workspace/a.txt", size: 4 })).toEqual({ allow: true }); + expect(check({ kind: "fs.rm", path: "/workspace/a.txt" })).toEqual({ allow: true }); + }); + + it("allows a command that is already asking for nothing", () => { + expect(check({ kind: "shell.exec", command: "rm -rf /workspace", writable: false })).toEqual({ + allow: true, + }); + }); + + it("allows write access to a command the matcher would have asked about", () => { + // The only route to write access is an approval, so a command in + // this shape has already been through one. + expect( + check({ + kind: "shell.exec", + command: "rm -rf /workspace", + writable: true, + backend: "worker-shell", + }), + ).toEqual({ allow: true }); + }); + + it("narrows write access that no approval could have justified", () => { + // A recognized read never needed write access, so nothing upstream + // asked a human about it. Write access here means it arrived by + // some other route, and taking it away costs the caller nothing + // the matcher says it needed. + expect( + check({ + kind: "shell.exec", + command: "cat /workspace/a.txt", + writable: true, + backend: "worker-shell", + }), + ).toEqual({ allow: true, writable: false }); + }); + + it("assumes the default backend when the action does not name one", () => { + // The pull path and any caller that skipped backend resolution + // land here. Reading it as the default is what keeps the matcher + // applying to the backend the matcher is for. + expect(check({ kind: "shell.exec", command: "cat /workspace/a.txt", writable: true })).toEqual({ + allow: true, + writable: false, + }); + }); + + it("never refuses outright", async () => { + // A gate cannot ask a human — by the time the action exists there + // is nowhere to suspend it — and refusing a command the model was + // told it could run turns a policy question into an unexplained + // failure. Narrowing is the answer it has. + for (const command of ["cat /workspace/a.txt", "rm -rf /", "frobnicate"]) { + for (const writable of [true, false]) { + const decision = await check({ kind: "shell.exec", command, writable }); + expect(decision.allow, command).toBe(true); + } + } + }); + + it("honours a policy other than the default", async () => { + const trusting = createApprovalGate({ rules: { "worker-shell": "never" }, fallback: "always" }); + // Under "never" nothing needs approval, so nothing justifies write + // access and every command is narrowed. + expect( + await trusting.check({ kind: "shell.exec", command: "rm -rf /workspace", writable: true }), + ).toEqual({ allow: true, writable: false }); + }); +}); + +describe("createAudit", () => { + const action: WorkspaceAction = { + kind: "shell.exec", + command: "rm -rf /workspace", + writable: true, + }; + + function auditWith(outcome: AuditOutcome) { + const audit = createAudit({}); + audit.record(action, outcome); + return audit.records()[0]; + } + + it("records what ran and whether it could write", () => { + const record = auditWith({ status: "allowed", writable: true }); + expect(record.kind).toBe("shell.exec"); + expect(record.target).toBe("rm -rf /workspace"); + expect(record.status).toBe("allowed"); + expect(record.writable).toBe(true); + }); + + it("records a refusal and why", () => { + const record = auditWith({ status: "denied", reason: "no" }); + expect(record.status).toBe("denied"); + expect(record.detail).toBe("no"); + // Write access is not a fact about an action that never ran. + expect(record.writable).toBeUndefined(); + }); + + it("records a failure with the message rather than the error", () => { + const record = auditWith({ status: "failed", error: new Error("EROFS") }); + expect(record.status).toBe("failed"); + expect(record.detail).toBe("EROFS"); + }); + + it("names a filesystem action by its path", () => { + const audit = createAudit({}); + audit.record( + { kind: "fs.write", path: "/workspace/a.txt", size: 4 }, + { + status: "allowed", + writable: true, + }, + ); + expect(audit.records()[0].target).toBe("/workspace/a.txt"); + }); + + it("hands each record to the sink as it happens", () => { + const seen: string[] = []; + const audit = createAudit({ sink: (record) => seen.push(record.target) }); + audit.record(action, { status: "allowed", writable: true }); + expect(seen).toEqual(["rm -rf /workspace"]); + }); + + it("keeps the trail bounded", () => { + // A log is not a database. An audit hook that grows without limit + // inside a durable object is a memory leak with good intentions. + const audit = createAudit({ limit: 3 }); + for (const command of ["one", "two", "three", "four"]) { + audit.record( + { kind: "shell.exec", command, writable: false }, + { + status: "allowed", + writable: false, + }, + ); + } + expect(audit.records().map((record) => record.target)).toEqual(["two", "three", "four"]); + }); + + it("hands out a copy, so a reader cannot edit the trail", () => { + const audit = createAudit({}); + audit.record(action, { status: "allowed", writable: true }); + audit.records().length = 0; + expect(audit.records()).toHaveLength(1); + }); +}); + +describe("the default policy and the tools agree", () => { + it("has a rule for every backend the model can name", () => { + // A backend the model can pick but the policy has no rule for + // falls back to "always": safe, but it costs a human every command + // and nobody meant to configure that. + for (const backend of Object.keys(EXEC_BACKENDS)) { + expect(DEFAULT_APPROVAL_POLICY.rules, backend).toHaveProperty(backend); + } + }); +}); diff --git a/examples/agent/src/agent.ts b/examples/agent/src/agent.ts new file mode 100644 index 00000000..ea364813 --- /dev/null +++ b/examples/agent/src/agent.ts @@ -0,0 +1,288 @@ +/** + * The agent, and the three places the policy meets the workspace. + * + * The whole example turns on one line, in `execTool` below: + * + * writable: (input) => decideApproval(input, policy).needsApproval + * + * which reads backwards until you notice when it runs. The AI SDK only + * calls a tool's `execute` after approval has been granted, so a + * command that needed approval and reached `execute` is a command a + * human said yes to. Driving `writable` off the same predicate as the + * approval therefore says exactly this: + * + * a command runs with write access ⇔ a human approved it + * + * Everything else in this file exists to keep that true for callers + * that are not the model, and to leave a record of what happened. + * + * The three seams, and what each is for: + * + * toolApproval Decides whether the turn pauses for a human. This + * is the only one of the three that can ask a + * question, because it is the only one that runs + * before the command exists as an action. + * + * writable Decides what the command may do. Withholding it is + * what makes a wrong guess survivable, and it is + * enforced by the filesystem rather than by anything + * in this file. + * + * gate The last word, for every caller. The tool layer + * covers the model's path only; the gate also sees + * the HTTP routes in src/index.ts and anything added + * later. It cannot ask a human — by the time an + * action exists there is nowhere to suspend it — so + * it narrows instead of asking. + * + * And the audit hook, which decides nothing and records everything. + */ + +import type { + AuditOutcome, + WorkspaceAction, + WorkspaceAudit, + WorkspaceGate, +} from "@cloudflare/computer"; +import { + createAITools, + createExecTool, + type ExecToolInput, + type ExecWorkspaceLike, +} from "@cloudflare/computer/tools"; +import type { ToolSet } from "ai"; + +import { type ApprovalPolicy, DEFAULT_APPROVAL_POLICY, decideApproval } from "./approval-policy.js"; + +/** The backend a command runs on when the model names none. */ +export const DEFAULT_BACKEND = "worker-shell"; + +/** + * What the model is told about each backend. Written to be chosen + * between: the model picks a backend from these descriptions, and the + * policy then decides what that choice costs in human attention. + */ +export const EXEC_BACKENDS = { + "worker-shell": { + description: + "just-bash in a Dynamic Worker. Cold-starts fast, no container, no public network. " + + "Covers cat, ls, grep, find, head, tail, sort, wc, diff, and git plumbing. " + + "Cannot run npm, node, python, or any binary outside just-bash's built-in command set. " + + "Recognized read-only commands run here without interrupting the user, so prefer it for " + + "anything you are only inspecting.", + }, + "worker-javascript": { + description: + "An ECMAScript module evaluated in a Dynamic Worker, with the workspace on node:fs. " + + "Use it for computation over files that would be awkward as a shell pipeline. " + + "Every module needs the user's approval before it runs.", + }, + "container-shell": { + description: + "computerd in a Cloudflare Container: a full Linux userland with real coreutils, node, " + + "npm, and public network access. Use it when the lighter backends cannot run the " + + "command. Every command needs the user's approval before it runs, so reach for it last.", + }, +} as const; + +export const SYSTEM_PROMPT = [ + "You are an assistant working in a Cloudflare Workspace rooted at /workspace.", + "", + "Tools:", + " - read, ls: inspect the tree. Prefer these over `exec cat` and `exec ls`.", + " - exec: run a command on one of three backends. Start with the", + " default worker-shell backend and move to a heavier one only", + " when it cannot run what you need.", + "", + "Three things about exec are worth understanding, because they change how you", + "should read a failure.", + "", + "First, some commands pause the turn while the user approves them. That is", + "normal and not an error. Commands that are recognizably read-only on the", + "worker-shell backend run without asking; everything else asks.", + "", + "Second, a command that ran without approval ran without write access, and", + "its writes fail with EROFS or a read-only error. The result tells you which", + "case you are in: it carries a `writable` field. If a command failed on a", + "write while `writable` was false, do not retry it unchanged and do not work", + "around it — say what you were trying to change and let the user decide.", + "Retrying the same command verbatim will fail the same way.", + "", + "Third, on the container-shell backend a command without write access still", + "appears to succeed: it writes to the container's own copy of the files and", + "exits zero, and the changes are discarded when they are pulled back. A", + "`discardedWrites` field on the result means exactly that happened. Treat it", + "as a failure however good the exit code looks, report which paths were lost,", + "and do not claim the work is done.", + "", + "Keep replies concise. Read files rather than guessing at their contents.", +].join("\n"); + +export interface AgentToolOptions { + workspace: ExecWorkspaceLike & Parameters[0]["workspace"]; + policy?: ApprovalPolicy; + /** Notified for every exec, so a caller can log or display it. */ + onExec?: (record: { command: string; backend: string; writable: boolean }) => void; +} + +/** + * The model's toolset. + * + * Read and list are handed over freely; every mutation goes through + * `exec`. That is deliberate. The write and edit tools in + * `createAITools` would let the model change files without any of this + * ever being consulted, and an example that asks carefully about `find + * -delete` while a `write` tool sits unguarded next to it is telling + * the reader something false about where the boundary is. One door. + */ +export function createAgentTools(options: AgentToolOptions): ToolSet { + const policy = options.policy ?? DEFAULT_APPROVAL_POLICY; + + const tools = createAITools({ workspace: options.workspace, readonly: true }); + + tools.exec = createExecTool({ + workspace: options.workspace, + backends: EXEC_BACKENDS, + defaultBackend: DEFAULT_BACKEND, + + // The line the example is about. `execute` runs only once approval + // has been granted, so asking for write access exactly when + // approval was required is what ties the capability to the human. + // + // Note which way this fails. The matcher calling a write a read + // costs the command its write access, and it fails visibly with + // EROFS instead of writing. The matcher calling a read a write + // costs somebody a question. Neither outcome loses a file, which + // is the only reason a heuristic is allowed near this decision. + writable: (input: ExecToolInput) => { + const writable = decideApproval( + { command: input.command, backend: input.backend }, + policy, + ).needsApproval; + options.onExec?.({ command: input.command, backend: input.backend, writable }); + return writable; + }, + }); + + return tools; +} + +/** + * The `toolApproval` entry for `streamText`. + * + * Must stay a pure function of the input. The SDK re-runs it when a + * paused turn resumes, and an approved call whose answer has since + * changed is converted into a denial — so anything time-dependent here + * would make approvals decay on their own. + */ +export function execApproval(policy: ApprovalPolicy = DEFAULT_APPROVAL_POLICY) { + return (input: { command: string; backend?: string }) => + decideApproval({ command: input.command, backend: input.backend ?? DEFAULT_BACKEND }, policy) + .needsApproval + ? ("user-approval" as const) + : ("not-applicable" as const); +} + +/** + * A gate over every caller, not just the model's. + * + * This is not a second copy of the approval decision, and it is worth + * being clear about what it is instead. It cannot ask anybody + * anything: a gate runs once the action exists, and there is nowhere + * to suspend a running command that does not risk a partial result. + * What it can do is refuse write access that nothing upstream + * justified. + * + * So it checks the invariant rather than re-deriving the decision. A + * command holding write access should be a command the matcher would + * have raised a question about, because that is the only route to + * write access through the tool layer. If a command that the matcher + * calls a plain read turns up asking for write access anyway, then it + * did not come through that route — a new caller, or an edited + * resolver — and it is narrowed back to read-only. Narrowing costs + * that caller nothing it needed: the matcher just said the command + * only reads. + * + * Filesystem actions are allowed through. Nothing the model can call + * reaches them, and the HTTP routes that do are how the workspace gets + * seeded in the first place. They are still audited. + */ +export function createApprovalGate( + policy: ApprovalPolicy = DEFAULT_APPROVAL_POLICY, +): WorkspaceGate { + return { + check(action: WorkspaceAction) { + // Switch on the kind rather than assuming: an action kind added + // to the seam later should stay permitted rather than break a + // caller that was never asked about it. + if (action.kind !== "shell.exec") return { allow: true }; + if (!action.writable) return { allow: true }; + + const decision = decideApproval( + { command: action.command, backend: action.backend ?? DEFAULT_BACKEND }, + policy, + ); + if (decision.needsApproval) return { allow: true }; + + return { allow: true, writable: false }; + }, + }; +} + +/** One line of the audit trail. */ +export interface AuditRecord { + at: number; + kind: WorkspaceAction["kind"]; + /** The command, for an exec; the path, for a filesystem action. */ + target: string; + status: AuditOutcome["status"]; + writable?: boolean; + detail?: string; +} + +/** + * An audit hook that keeps the last `limit` records and hands each one + * to `sink` as it happens. + * + * It decides nothing, and it is not allowed to. The seam swallows what + * this throws, on the grounds that the action has already happened and + * failing a caller over a lost log line would quietly turn the audit + * hook into a gate. The ring buffer is bounded for the same reason a + * log is not a database: this is here to be read while the example is + * running, not to be the record of authority. + */ +export function createAudit(options: { + limit?: number; + sink?: (record: AuditRecord) => void; +}): WorkspaceAudit & { records(): AuditRecord[] } { + const limit = options.limit ?? 100; + const ring: AuditRecord[] = []; + + return { + record(action: WorkspaceAction, outcome: AuditOutcome) { + const entry: AuditRecord = { + at: Date.now(), + kind: action.kind, + target: action.kind === "shell.exec" ? action.command : action.path, + status: outcome.status, + writable: outcome.status === "allowed" ? outcome.writable : undefined, + detail: + outcome.status === "denied" + ? outcome.reason + : outcome.status === "failed" + ? describe(outcome.error) + : undefined, + }; + ring.push(entry); + if (ring.length > limit) ring.shift(); + options.sink?.(entry); + }, + records() { + return [...ring]; + }, + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/examples/agent/src/approval-policy.effects.test.ts b/examples/agent/src/approval-policy.effects.test.ts new file mode 100644 index 00000000..4036bd18 --- /dev/null +++ b/examples/agent/src/approval-policy.effects.test.ts @@ -0,0 +1,252 @@ +/** + * 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 + * `worker-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. + * + * The other two backends are not exercised, and neither has a claim + * here to exercise. `container-shell` runs GNU coreutils rather than + * just-bash, so the same command can behave differently there; + * `worker-javascript` evaluates a module, which this matcher does not + * read at all. The default policy gates both outright, which is why + * the difference does not bite, and is a reason to keep gating them. + * + * What this file does not test, and what nothing in this example + * needs it to test, is whether a write the policy misjudged actually + * lands. That is the workspace's job: a command that was not approved + * runs without write access, and the filesystem refuses it whatever + * this matcher concluded. This file is about how many questions get + * asked, not about what happens when the answer is wrong. + */ + +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: { "worker-shell": "never" as const } }; + const command = "rm -rf /workspace/sub"; + expect(decideApproval({ command, backend: "worker-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: "worker-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/agent/src/approval-policy.test.ts b/examples/agent/src/approval-policy.test.ts new file mode 100644 index 00000000..a633e15d --- /dev/null +++ b/examples/agent/src/approval-policy.test.ts @@ -0,0 +1,270 @@ +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-shell")).toBe(true); + expect(gates("uname -a", "container-shell")).toBe(true); + }); + + it("gates every module on the JavaScript backend", () => { + // A module's effects are a function of what it imports and + // computes, not of a verb at the front of a line. There is no + // allowlist here to be conservative with. + expect(gates("export default async () => 2 + 2;", "worker-javascript")).toBe(true); + }); + + it("gates a command that the same rule set waves through on the shell", () => { + // Identical command, different backend: proves the rule is + // per-backend rather than per-command. + expect(gates("cat /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("cat /workspace/hello.txt", "container-shell")).toBe(true); + }); + }); + + describe("the 'never' rule", () => { + const trusting: ApprovalPolicy = { rules: { "worker-shell": "never" }, fallback: "always" }; + + it("waves through even a destructive command", () => { + expect(gates("rm -rf /workspace", "worker-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", () => { + it("waves through recognized reads", () => { + expect(gates("cat /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("ls -la /workspace", "worker-shell")).toBe(false); + expect(gates("grep -n needle /workspace/haystack.txt", "worker-shell")).toBe(false); + expect(gates("find /workspace -name '*.ts'", "worker-shell")).toBe(false); + expect(gates("wc -l /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("head -n 5 /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("stat /workspace/hello.txt", "worker-shell")).toBe(false); + }); + + it("waves through read-only git plumbing", () => { + expect(gates("git status", "worker-shell")).toBe(false); + expect(gates("git log --oneline -5", "worker-shell")).toBe(false); + expect(gates("git diff HEAD", "worker-shell")).toBe(false); + }); + + it("gates git subcommands that write", () => { + expect(gates("git commit -m wip", "worker-shell")).toBe(true); + expect(gates("git push origin main", "worker-shell")).toBe(true); + expect(gates("git checkout -b feature", "worker-shell")).toBe(true); + expect(gates("git", "worker-shell")).toBe(true); + }); + + it("gates mutating commands", () => { + expect(gates("rm -rf /workspace", "worker-shell")).toBe(true); + expect(gates("mv /workspace/a /workspace/b", "worker-shell")).toBe(true); + expect(gates("mkdir -p /workspace/deep/dir", "worker-shell")).toBe(true); + expect(gates("chmod 777 /workspace/hello.txt", "worker-shell")).toBe(true); + expect(gates("npm install", "worker-shell")).toBe(true); + }); + + it("gates redirection, even of a read", () => { + expect(gates("cat /workspace/a > /workspace/b", "worker-shell")).toBe(true); + expect(gates("cat /workspace/a >> /workspace/b", "worker-shell")).toBe(true); + expect(gates("cat < /workspace/a", "worker-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", "worker-shell")).toBe(false); + expect(gates("cat /workspace/a | grep needle", "worker-shell")).toBe(false); + expect(gates("cat /workspace/a | grep needle | wc -l", "worker-shell")).toBe(false); + expect(gates("ls /workspace || true", "worker-shell")).toBe(false); + expect(gates("ls /workspace && cat /workspace/a", "worker-shell")).toBe(false); + expect(gates("cd /workspace; ls", "worker-shell")).toBe(true); + }); + + it("gates a pipeline with a stage that is not a read", () => { + expect(gates("ls /workspace; rm -rf /workspace", "worker-shell")).toBe(true); + expect(gates("ls /workspace && rm -rf /workspace", "worker-shell")).toBe(true); + expect(gates("cat /workspace/a | tee /workspace/b", "worker-shell")).toBe(true); + expect(gates("find /workspace -type f | xargs rm", "worker-shell")).toBe(true); + expect(gates("ls /workspace | sed -i s/a/b/", "worker-shell")).toBe(true); + }); + + it("names the offending stage when it gates a pipeline", () => { + expect( + decideApproval({ command: "ls /workspace | tee /workspace/b", backend: "worker-shell" }) + .reason, + ).toContain("tee"); + }); + + it("gates an empty or dangling stage", () => { + expect(gates("ls /workspace |", "worker-shell")).toBe(true); + expect(gates("| wc -l", "worker-shell")).toBe(true); + expect(gates("ls &&", "worker-shell")).toBe(true); + }); + + it("still gates backgrounding, which leaves something running", () => { + expect(gates("ls /workspace &", "worker-shell")).toBe(true); + expect(gates("cat /workspace/a & cat /workspace/b", "worker-shell")).toBe(true); + }); + + it("still gates redirection inside a pipeline", () => { + expect(gates("ls /workspace | wc -l > /workspace/count", "worker-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", "worker-shell")).toBe(false); + expect(gates("ls /workspace && echo done", "worker-shell")).toBe(false); + expect(gates("echo hello > /workspace/x", "worker-shell")).toBe(true); + }); + + it("gates command substitution", () => { + expect(gates("cat $(ls /workspace)", "worker-shell")).toBe(true); + expect(gates("cat `ls /workspace`", "worker-shell")).toBe(true); + expect(gates("cat /workspace/$(whoami)", "worker-shell")).toBe(true); + }); + + it("gates a newline that hides a second command", () => { + expect(gates("ls /workspace\nrm -rf /workspace", "worker-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", "worker-shell")).toBe(true); + expect(gates("find /workspace -name x -exec rm {} +", "worker-shell")).toBe(true); + expect(gates("find /workspace -execdir rm {} +", "worker-shell")).toBe(true); + expect(gates("find /workspace -fprint /workspace/out", "worker-shell")).toBe(true); + expect(gates("sort -o /workspace/out /workspace/in", "worker-shell")).toBe(true); + expect(gates("sort --output=/workspace/out /workspace/in", "worker-shell")).toBe(true); + }); + + it("still waves through the read flags those verbs are used with", () => { + expect(gates("find /workspace -name '*.ts'", "worker-shell")).toBe(false); + expect(gates("find /workspace -type f -maxdepth 2", "worker-shell")).toBe(false); + expect(gates("find /workspace -mtime -1", "worker-shell")).toBe(false); + expect(gates("sort -n /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("sort -u -r /workspace/hello.txt", "worker-shell")).toBe(false); + }); + + it("gates an unrecognized flag on a checked verb", () => { + expect(gates("find /workspace -frobnicate", "worker-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", "worker-shell")).toBe(true); + expect(gates("sed -i s/a/b/ /workspace/hello.txt", "worker-shell")).toBe(true); + expect(gates("sed 'w /workspace/out' /workspace/hello.txt", "worker-shell")).toBe(true); + expect(gates("uniq /workspace/in /workspace/out", "worker-shell")).toBe(true); + expect(gates("tree -o /workspace/out", "worker-shell")).toBe(true); + expect(gates("date -s 12:00", "worker-shell")).toBe(true); + }); + + it("strips leading environment assignments before reading the verb", () => { + expect(gates("LC_ALL=C sort /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("LC_ALL=C rm /workspace/hello.txt", "worker-shell")).toBe(true); + }); + + it("reads the verb out of an absolute path", () => { + expect(gates("/bin/cat /workspace/hello.txt", "worker-shell")).toBe(false); + expect(gates("/bin/rm /workspace/hello.txt", "worker-shell")).toBe(true); + }); + + it("gates an unrecognized command rather than guessing", () => { + expect(gates("frobnicate --hard", "worker-shell")).toBe(true); + expect(gates("", "worker-shell")).toBe(true); + expect(gates(" ", "worker-shell")).toBe(true); + }); + }); + + describe("the decision itself", () => { + it("explains every gate it raises", () => { + const commands: Array<[string, string]> = [ + ["rm -rf /workspace", "worker-shell"], + ["cat /workspace/a > /workspace/b", "worker-shell"], + ["uname -a", "container-shell"], + ["export default async () => 2 + 2;", "worker-javascript"], + ["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: "worker-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-shell" }).reason).toContain( + "container-shell", + ); + }); + + 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: "find /workspace -delete", backend: "worker-shell" }; + const first = decideApproval(call); + for (let i = 0; i < 5; i++) { + expect(decideApproval(call)).toEqual(first); + } + }); + }); + + describe("DEFAULT_APPROVAL_POLICY", () => { + it("runs the matcher on just-bash alone and gates the rest outright", () => { + expect(DEFAULT_APPROVAL_POLICY.rules).toEqual({ + "worker-shell": "read-only", + "worker-javascript": "always", + "container-shell": "always", + }); + expect(DEFAULT_APPROVAL_POLICY.fallback).toBe("always"); + }); + + it("names every backend the workspace registers", () => { + // A backend missing from the table falls back to "always", which + // is safe but silently costs a human every command. Pinning the + // ids here means adding a backend to src/index.ts without + // deciding its rule fails a test rather than degrading quietly. + expect(Object.keys(DEFAULT_APPROVAL_POLICY.rules).sort()).toEqual([ + "container-shell", + "worker-javascript", + "worker-shell", + ]); + }); + }); +}); diff --git a/examples/agent/src/approval-policy.ts b/examples/agent/src/approval-policy.ts new file mode 100644 index 00000000..ed0b956a --- /dev/null +++ b/examples/agent/src/approval-policy.ts @@ -0,0 +1,426 @@ +/** + * Which commands a human has to approve before the agent runs them. + * + * This file decides how many questions to ask. It does not decide what + * a command can do — the workspace does that, by handing the backend a + * filesystem handle without write access, and every write through that + * handle fails whatever this matcher believed. Reading a command line + * to guess its effect is a heuristic, and a heuristic is the wrong + * thing to put between a model and a filesystem. Keeping the two + * separate is what makes it safe for the matcher to be wrong. + * + * The one invariant tying them together: + * + * a command runs with write access ⇔ a human approved it + * + * So the matcher's failure modes are asymmetric by construction. Fail + * closed — call a read a write — and someone is asked a question they + * did not need to answer. Fail open — call a write a read — and the + * command runs without the capability it needed and fails, which is a + * bad command rather than a lost file. Neither is good; only one is + * unrecoverable, and this file cannot reach it. + * + * There are two tiers, and they are not equally trustworthy. + * + * The first is a table keyed by backend id, deciding on what a backend + * can reach rather than on what a command says. Nothing is parsed to + * apply it, so nothing about it can be fooled by a command it did not + * anticipate. + * + * The second is the `read-only` rule, 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. `approval-policy.effects.test.ts` holds it to that by + * running every command this file would allow and failing if any of + * them wrote. + * + * `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"; + +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; +} + +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, and the reasoning is per backend rather than + * per command. + * + * `worker-shell` runs just-bash against the workspace filesystem and + * nothing else, and a bash line is the one dialect here whose effect + * can be read off its text with any confidence. It gets the matcher. + * + * `container-shell` is a full Linux userland with a public network, so + * "which command is it" is the wrong question to be asking. It is also + * the backend where a refused write is caught late — the command + * writes to the container's own copy of the tree and the refusal lands + * when those changes are pulled back — so it is the backend where the + * capability is least able to cover for a bad guess. Both reasons + * point the same way. + * + * `worker-javascript` evaluates a module. Its effects are a function + * of what it imports and computes rather than of any verb, and there + * is no small allowlist that captures them, so there is nothing here + * to be conservative with. Gated outright. + */ +export const DEFAULT_APPROVAL_POLICY: ApprovalPolicy = { + rules: { + "worker-shell": "read-only", + "worker-javascript": "always", + "container-shell": "always", + }, + fallback: "always", +}; + +/** + * 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"], + ["$", "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"], +]; + +/** + * 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. `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. `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. + */ +// 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", + "cut", + "df", + "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", + "find", + "grep", + "head", + "id", + "ls", + "pwd", + "readlink", + "realpath", + "sort", + "stat", + "tail", + "test", + "true", + "uname", + "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 a named set of what is + * allowed 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. + */ +export 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", + "cat-file", + "describe", + "diff", + "log", + "ls-files", + "ls-tree", + "rev-parse", + "shortlog", + "show", + "status", +]); + +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 verdict = classifyShellLine(call.command); + return { needsApproval: !verdict.readOnly, reason: verdict.reason }; +} + +interface Verdict { + readOnly: boolean; + reason: string; +} + +/** + * 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) { + if (command.includes(character)) { + const shown = character === "\n" || character === "\r" ? "a newline" : `"${character}"`; + return { readOnly: false, reason: `${shown} ${effect}` }; + } + } + + // 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+/) + .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` }; + } + + 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}"`, + }; + } + } + } + + 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` }; +} diff --git a/examples/agent/src/index.ts b/examples/agent/src/index.ts new file mode 100644 index 00000000..3a4c497b --- /dev/null +++ b/examples/agent/src/index.ts @@ -0,0 +1,382 @@ +// Example Worker + Durable Object holding one Workspace with three +// backends behind it. +// +// The example exists to show an agent deciding what a command is +// allowed to do before it runs it, so it needs more than one place +// where a command can run: the three backends enforce a withheld write +// capability differently, and the difference is the point rather than +// an inconvenience. +// +// worker-shell just-bash in a Dynamic Worker. Reaches the +// workspace over RPC, so a withheld capability +// turns every write into EROFS inside the +// command, before anything lands. +// +// worker-javascript an ECMAScript module in a Dynamic Worker. +// Same story: the module's writes go through the +// same filesystem handle and fail the same way. +// +// container-shell computerd over real coreutils, writing to its +// own copy of the tree and syncing back. Nothing +// stops the write there; the refusal happens when +// the changes are pulled, and shows up as +// skipped entries rather than as a failed +// command. +// +// Wire shape: +// +// client ──► Worker /c//{file,exec} +// │ (DO RPC) +// ▼ +// AgentExample DO ──► Workspace ──┬─► WorkerShellBackend ──► Dynamic Worker +// ├─► WorkerJavaScriptBackend ──► Dynamic Worker +// └─► CloudflareContainerBackend ──► computerd + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + getWorkspace, + type WorkspaceOptions, + WorkspaceProxy, + type WorkspaceRuntimeLoader, + WorkspaceServiceProxy, + withWorkspace, +} from "@cloudflare/computer"; +import { + CloudflareContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai"; +import { createWorkersAI } from "workers-ai-provider"; + +import { + createAgentTools, + createApprovalGate, + createAudit, + DEFAULT_BACKEND, + execApproval, + SYSTEM_PROMPT, +} from "./agent.js"; + +// Re-exported so the runtime can wrap each class into a loopback +// binding: the container backend reaches the DO through +// ctx.exports.WorkspaceProxy, the Dynamic Worker shell through +// ctx.exports.WorkspaceServiceProxy. The classes live in +// @cloudflare/computer; the re-export is what puts them in this +// Worker's top-level module graph. +export { WorkspaceProxy, WorkspaceServiceProxy }; + +// The container half of the DO. The backend is a field here rather +// than on AgentExample because withWorkspace's options callback needs +// it while constructing the Workspace: base-class fields are +// initialized by the time that callback runs, subclass fields are not. +class AgentBase extends withWorkspaceContainer(class extends DurableObject {}) { + readonly container_backend: CloudflareContainerBackend = new CloudflareContainerBackend({ + container: () => this, + workspace: { binding: "AgentExample", id: this.ctx.id.toString() }, + }); + + // Held as a field so the /audit route can read the trail back out. + // A real host would ship these somewhere durable; the ring buffer is + // here to be read while the example runs. + readonly audit = createAudit({ + sink: (record) => { + console.log(JSON.stringify({ audit: record })); + }, + }); +} + +// Named, with an explicit return type: an inline callback would make +// the class's own base expression part of its inferred type. +// DurableObject keeps ctx and env protected, so read them through a +// cast the way the mixin's own docs do. +function workspaceOptions(self: InstanceType): WorkspaceOptions { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + + // One binding, two backends, and a cast to get it there. The + // generated WorkerLoader type does satisfy what both backends ask + // for, but checking it against them here overruns tsc's + // instantiation depth limit: Env holds a namespace of AgentExample, + // which is declared below in terms of the mixin, so resolving the + // argument reenters the class it is being resolved for. The single + // backend in examples/worker-shell stays under the limit and needs + // no cast; three backends in one function does not. + // + // The cast names both target shapes rather than going through `any`, + // so each constructor call below is still checked against the + // options it actually takes. + const loader = env.LOADER as unknown as NonNullable< + ConstructorParameters[0]["loader"] + > & + WorkspaceRuntimeLoader; + + const backends: WorkspaceOptions["backends"] = [ + new WorkerShellBackend({ + loader, + workspace: { binding: "AgentExample", id: ctx.id.toString() }, + ctx, + }), + new WorkerJavaScriptBackend({ loader }), + self.container_backend, + ]; + return { + // ctx.storage.sql.exec returns a narrower row type than + // DurableObjectStorageLike declares; the runtime shape matches. + // Cast through unknown to bypass invariance. + storage: ctx.storage as unknown as DurableObjectStorageLike, + waitUntil: ctx.waitUntil.bind(ctx), + // The first backend is the default, so a command that names no + // backend runs under just-bash: the one of the three that + // refuses a withheld write outright. + backends, + // Both seams are installed on the Workspace rather than around the + // agent, which is the point of them being here: they cover the + // HTTP routes below and anything added later, not only the model's + // path through the tools. + gate: createApprovalGate(), + audit: self.audit, + }; +} + +export class AgentExample extends withWorkspace(AgentBase, workspaceOptions) { + // computerd dials back with a /ws upgrade; everything else on this + // door is the agent, whose response is a stream and so cannot come + // back over an RPC method. + override async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/ws") return this.container_backend.handleFetch(request); + if (url.pathname === "/agent") return this.#agent(request); + if (url.pathname === "/audit") return json(this.audit.records()); + return new Response("not found", { status: 404 }); + } + + async #agent(request: Request): Promise { + let body: { messages?: UIMessage[] }; + try { + body = (await request.json()) as { messages?: UIMessage[] }; + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const messages = body.messages ?? []; + + // getWorkspace(this) takes the local path: no RPC, and it awaits + // ready() on the way through. + const workspace = await getWorkspace(this); + const workersai = createWorkersAI({ binding: this.env.AI }); + + const result = streamText({ + model: workersai(MODEL_ID), + system: SYSTEM_PROMPT, + messages: await convertToModelMessages(messages), + tools: createAgentTools({ workspace }), + // Approval is decided here rather than on the tool, which is + // where the AI SDK moved it: the tool-level `needsApproval` + // option is deprecated in v7. + toolApproval: { exec: execApproval() }, + // The conversation lives on the client, so an approval reaches + // this Worker as a claim the client makes about a decision the + // user supposedly took. Signing the request when it is issued + // and checking the signature when it comes back is what stops a + // client from writing itself an approval for a command nobody + // saw. Without this the gate and the capability are still in + // place, but the human in the loop is only advisory. + experimental_toolApprovalSecret: await this.#approvalSecret(), + stopWhen: stepCountIs(16), + }); + + return result.toUIMessageStreamResponse(); + } + + /** + * The HMAC key for signing approval requests. + * + * Generated on first use and kept in this DO's own storage rather + * than configured as a secret. It never has to leave the object that + * both issues and verifies the signature, so there is nothing for an + * operator to set up and nothing to leak through a binding. + */ + async #approvalSecret(): Promise { + const existing = await this.ctx.storage.get(APPROVAL_SECRET_KEY); + if (existing !== undefined) return existing; + const secret = crypto.getRandomValues(new Uint8Array(32)); + await this.ctx.storage.put(APPROVAL_SECRET_KEY, secret); + return secret; + } +} + +const MODEL_ID = "@cf/zai-org/glm-5.2"; +const APPROVAL_SECRET_KEY = "approval-secret"; + +// --------------------------------------------------------------- +// Worker HTTP surface +// --------------------------------------------------------------- + +interface ExecRequest { + command?: string; + backend?: string; + cwd?: string; +} + +// computerd mounts the VFS at /workspace inside the container, and the +// two Dynamic Worker backends see the same tree at the same path. The +// file handler holds every path it touches under that root: the +// example means to expose the mounted tree and nothing else. +const MOUNT_ROOT = "/workspace"; + +function resolveMountPath(rest: string): string | null { + const candidate = `/${rest}`; + if (candidate !== MOUNT_ROOT && !candidate.startsWith(`${MOUNT_ROOT}/`)) { + return null; + } + if (candidate.split("/").includes("..")) return null; + return candidate; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + const fileMatch = url.pathname.match(/^\/c\/([^/]+)\/file\/(.+)$/); + if (fileMatch) { + const resolved = resolveMountPath(fileMatch[2]); + if (resolved === null) { + return errorJSON(new Error(`path must sit under ${MOUNT_ROOT}; got /${fileMatch[2]}`), 400); + } + return handleFile(request, env, fileMatch[1], resolved); + } + + const execMatch = url.pathname.match(/^\/c\/([^/]+)\/exec\/?$/); + if (execMatch) return handleExec(request, env, execMatch[1]); + + // The agent's reply is a stream and the audit trail lives on the + // object, so both are handled by the DO itself rather than through + // an RPC method. Rewritten onto the path the DO's fetch switches + // on; the workspace name stays in the id, not the URL. + const agentMatch = url.pathname.match(/^\/c\/([^/]+)\/(agent|audit)\/?$/); + if (agentMatch) { + const stub = env.AgentExample.get(env.AgentExample.idFromName(agentMatch[1])); + return stub.fetch(new Request(`http://do/${agentMatch[2]}`, request)); + } + + if (url.pathname === "/" || url.pathname === "") { + return new Response( + [ + "agent example", + "", + ` PUT /c//file/workspace/ write file at ${MOUNT_ROOT}/`, + ` GET /c//file/workspace/ read file at ${MOUNT_ROOT}/`, + " POST /c//exec run a shell command (JSON result)", + " POST /c//agent one agent turn (UI message stream)", + " GET /c//audit what the audit hook recorded", + "", + `Default exec backend: ${DEFAULT_BACKEND}. Talk to the agent with`, + "`npm run chat --workspace @example/computer-agent`.", + "", + ].join("\n"), + { headers: { "content-type": "text/plain" } }, + ); + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +function workspaceFor(env: Env, name: string) { + const stub = env.AgentExample.get(env.AgentExample.idFromName(name)); + // `wrangler types` doesn't surface the accessor the withWorkspace + // mixin installs, so cast at the boundary. + return getWorkspace(stub as unknown as Parameters[0]); +} + +async function handleFile( + request: Request, + env: Env, + name: string, + path: string, +): Promise { + const ws = await workspaceFor(env, name); + + if (request.method === "PUT") { + const body = new Uint8Array(await request.arrayBuffer()); + try { + // Nothing has created /workspace yet on a fresh object. The other + // examples get it as a side effect of mounting a bucket + // underneath it; this one mounts nothing, so the first write to a + // new object would otherwise fail on a missing parent. Both calls + // pass the gate and land in the audit trail. + const parent = path.slice(0, path.lastIndexOf("/")); + if (parent.length > 0) await ws.fs.mkdir(parent, { recursive: true }); + await ws.fs.writeFile(path, body); + return new Response(null, { status: 204 }); + } catch (error) { + return errorJSON(error, 500); + } + } + + if (request.method === "GET") { + try { + const stream = await ws.fs.readFile(path, {}); + return new Response(stream, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === "ENOENT") return errorJSON(error, 404); + return errorJSON(error, 500); + } + } + + return new Response("method not allowed", { status: 405, headers: { allow: "GET, PUT" } }); +} + +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" } }); + } + + let body: ExecRequest; + try { + body = (await request.json()) as ExecRequest; + } catch { + return errorJSON(new Error("invalid JSON body"), 400); + } + if (typeof body.command !== "string" || body.command.length === 0) { + return errorJSON(new Error("must provide command"), 400); + } + + const ws = await workspaceFor(env, name); + try { + const handle = await ws.runtime.exec(body.command, { + backend: body.backend, + cwd: body.cwd, + encoding: "utf8", + }); + const result = await handle.result(); + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return errorJSON(error, 500); + } +} + +function json(body: unknown, status = 200): 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; + return new Response(JSON.stringify({ error: message, code }), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/examples/agent/tsconfig.json b/examples/agent/tsconfig.json new file mode 100644 index 00000000..4a2585dc --- /dev/null +++ b/examples/agent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/agent/worker-configuration.d.ts b/examples/agent/worker-configuration.d.ts new file mode 100644 index 00000000..88623c2f --- /dev/null +++ b/examples/agent/worker-configuration.d.ts @@ -0,0 +1,14 @@ +// Hand-written env shape for the platform Worker. Run +// `wrangler types` to regenerate from wrangler.jsonc when the +// bindings change. +// +// Kept to the bindings themselves rather than checking in the +// generated file, which also inlines the whole workerd type library. +// The runtime types come from the @cloudflare/workers-types +// devDependency, named in tsconfig.json alongside this file. + +interface Env { + AgentExample: DurableObjectNamespace; + LOADER: WorkerLoader; + AI: Ai; +} diff --git a/examples/agent/wrangler.jsonc b/examples/agent/wrangler.jsonc new file mode 100644 index 00000000..4ec425dd --- /dev/null +++ b/examples/agent/wrangler.jsonc @@ -0,0 +1,59 @@ +{ + // Example: an agent that runs shell commands in a Workspace and + // asks a human before any command whose effect it cannot read off + // the command's own text. + // + // Three backends are wired into the one Workspace so the same + // policy can be watched deciding across all three: a Dynamic + // Worker running just-bash, a Dynamic Worker evaluating ECMAScript + // modules, and a container running computerd over real coreutils. + // Which one enforces write access preventively and which one only + // reports afterwards differs between them, and the example is + // arranged so that difference is visible rather than hidden. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-agent-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI, for the model the agent runs on. + "ai": { + "binding": "AI" + }, + + // One Worker Loader serves both Dynamic Worker backends: the + // shell mints `workspace-shell:`, the JavaScript backend its + // own id, so they do not collide. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "containers": [ + { + "class_name": "AgentExample", + "image": "./Dockerfile", + "instance_type": "standard-2", + "max_instances": 5, + "rollout_active_grace_period": 0, + "rollout_step_percentage": [100] + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "AgentExample", + "class_name": "AgentExample" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["AgentExample"] + } + ] +} diff --git a/examples/agent/wrangler.local.jsonc b/examples/agent/wrangler.local.jsonc new file mode 100644 index 00000000..67186097 --- /dev/null +++ b/examples/agent/wrangler.local.jsonc @@ -0,0 +1,48 @@ +{ + // Same as wrangler.jsonc, minus the container backend. + // + // `wrangler dev` builds the container image before it will start, so + // the whole example — including the two backends that need no + // container at all — is unavailable on a machine that cannot build + // it. This config drops the container so the rest can be run and + // demonstrated: + // + // npm run dev:local --workspace @example/computer-agent + // + // Keep the bindings below in step with wrangler.jsonc; this file + // differs from it only by the absence of the container. + // + // The `container-shell` backend is absent here, so asking the agent + // for it will fail. Everything else behaves as documented. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-agent-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + "ai": { + "binding": "AI" + }, + + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "AgentExample", + "class_name": "AgentExample" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["AgentExample"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 18ea2cf7..802f72b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,31 @@ "typescript": "^6.0.3" } }, + "examples/agent": { + "name": "@example/computer-agent", + "version": "0.0.0", + "dependencies": { + "@ai-sdk/tui": "^1.0.43", + "@cloudflare/computer": "*", + "ai": "^7.0.0", + "workers-ai-provider": "^4.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "just-bash": "^3.0.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7", + "wrangler": "^4.107.1" + } + }, + "examples/agent/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "examples/artifacts": { "name": "@example/computer-artifacts", "version": "0.0.0", @@ -2825,6 +2850,10 @@ "node": ">=18" } }, + "node_modules/@example/computer-agent": { + "resolved": "examples/agent", + "link": true + }, "node_modules/@example/computer-artifacts": { "resolved": "examples/artifacts", "link": true diff --git a/script/container-mount-probe.sh b/script/container-mount-probe.sh new file mode 100755 index 00000000..5fcd73d1 --- /dev/null +++ b/script/container-mount-probe.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Probe whether a container can give one command a read-only view of the mount point. +# +# Answers a single question: can `writable: false` be enforced +# preventively on the container backend, the way it already is on the +# two Dynamic Worker backends? +# +# Those backends hold no files. Every write is an RPC into the +# workspace, so a command without write access is handed a filesystem +# handle built without the capability and the write fails where it +# happens. A container has its own copy of the files and has already +# written to them by the time the host hears about the change, so the +# only move left is to refuse the change on the way back — which +# leaves the container's copy disagreeing with the workspace. +# +# A read-only bind mount inside a private mount namespace would close +# that gap: the kernel refuses the write before it reaches the +# filesystem, so nothing lands and there is nothing to refuse later. +# It is the same shape as the two filesystem handles over one store, +# with the kernel holding the capability instead of a JS object. +# +# That hinges on whether the container may create a mount namespace at +# all, which is a property of the runtime rather than of this repo. +# This script asks, and prints one of three verdicts: +# +# USERNS works unprivileged. Nothing to negotiate. +# CAP_SYS_ADMIN works, but depends on that capability being granted. +# UNAVAILABLE neither route works. Preventive enforcement is out; +# the after-the-fact refusal is the honest answer. +# +# Run it inside a *deployed* container. Local Docker is more +# permissive than the production sandbox and will report a pass that +# does not hold in production. +# +# Usage: ./container-mount-probe.sh [mount-point] (default /workspace) + +set -uo pipefail + +MOUNT_POINT="${1:-/workspace}" +PROBE_DIR="${MOUNT_POINT}/.mount-probe.$$" + +pass() { printf ' \033[32mok\033[0m %s\n' "$1"; } +fail() { printf ' \033[31mno\033[0m %s\n' "$1"; } +info() { printf ' %s\n' "$1"; } + +cleanup() { rm -rf "${PROBE_DIR}" 2>/dev/null; } +trap cleanup EXIT + +echo +echo "container mount probe — ${MOUNT_POINT}" +echo + +# --------------------------------------------------------------- +# Preconditions. A probe that cannot write in the first place would +# report every refusal below as a success. +# --------------------------------------------------------------- +echo "preconditions" + +if [ ! -d "${MOUNT_POINT}" ]; then + fail "${MOUNT_POINT} does not exist — pass the mount point as \$1" + exit 2 +fi +pass "${MOUNT_POINT} exists" + +if ! mkdir -p "${PROBE_DIR}" 2>/dev/null; then + fail "cannot write to ${MOUNT_POINT}; every refusal below would be a false pass" + exit 2 +fi +pass "${MOUNT_POINT} is writable, so a refusal below means something" + +for tool in unshare mount; do + if command -v "${tool}" >/dev/null 2>&1; then + pass "${tool} present" + else + fail "${tool} missing — install util-linux in the image before trusting this result" + fi +done +echo + +# --------------------------------------------------------------- +# The two routes to a private mount namespace. +# +# Each runs in its own process so a failure to mount is distinguished +# from a mount that succeeded and then failed to refuse the write. +# The distinction matters: the first is "not allowed to try", the +# second would be a kernel bug. +# --------------------------------------------------------------- +echo "route A — unprivileged user namespace (unshare -Urm)" + +a_mount=0 +a_refused=0 +if unshare -Urm true 2>/dev/null; then + pass "namespace created" + a_mount=1 + if unshare -Urm sh -c \ + "mount --bind -o ro '${MOUNT_POINT}' '${MOUNT_POINT}' 2>/dev/null" 2>/dev/null; then + pass "bind mount accepted" + if unshare -Urm sh -c \ + "mount --bind -o ro '${MOUNT_POINT}' '${MOUNT_POINT}' && touch '${PROBE_DIR}/a' 2>/dev/null" \ + 2>/dev/null; then + fail "write SUCCEEDED through a read-only bind mount — the mount is not doing its job" + else + pass "write refused" + a_refused=1 + fi + else + fail "bind mount rejected" + a_mount=0 + fi +else + fail "cannot create a user namespace" +fi +echo + +echo "route B — mount namespace only (unshare -m, needs CAP_SYS_ADMIN)" + +b_refused=0 +if unshare -m true 2>/dev/null; then + pass "namespace created" + if unshare -m sh -c \ + "mount --bind -o ro '${MOUNT_POINT}' '${MOUNT_POINT}' && touch '${PROBE_DIR}/b' 2>/dev/null" \ + 2>/dev/null; then + fail "write SUCCEEDED through a read-only bind mount — the mount is not doing its job" + else + # Distinguish "mounted and refused" from "never mounted". + if unshare -m sh -c \ + "mount --bind -o ro '${MOUNT_POINT}' '${MOUNT_POINT}' 2>/dev/null" 2>/dev/null; then + pass "write refused" + b_refused=1 + else + fail "bind mount rejected" + fi + fi +else + fail "cannot create a mount namespace" +fi +echo + +# --------------------------------------------------------------- +# Isolation. A read-only view that leaks past the one command is +# worse than none: it would let a read-only command disarm a writable +# one running beside it, which is the property the per-command +# capability exists to guarantee. +# --------------------------------------------------------------- +echo "isolation — does the read-only view stay inside its own command?" + +if [ "${a_mount}" = "1" ] || [ "${b_refused}" = "1" ]; then + if touch "${PROBE_DIR}/outer" 2>/dev/null; then + pass "the calling shell can still write; the namespace did not leak" + rm -f "${PROBE_DIR}/outer" + else + fail "the calling shell LOST write access — a read-only command would disarm its neighbours" + fi +else + info "skipped; no route produced a mount" +fi +echo + +# --------------------------------------------------------------- +# Context for whoever reads the transcript later. +# --------------------------------------------------------------- +echo "environment" +info "kernel: $(uname -sr 2>/dev/null || echo unknown)" +info "uid: $(id -u 2>/dev/null || echo unknown)" + +caps="$(awk '/^CapEff/{print $2}' /proc/self/status 2>/dev/null)" +if [ -n "${caps}" ]; then + if command -v capsh >/dev/null 2>&1; then + info "capeff: $(capsh --decode="${caps}" 2>/dev/null | head -1)" + else + info "capeff: ${caps} (install libcap2-bin for capsh --decode)" + fi +fi + +userns_max="$(sysctl -n user.max_user_namespaces 2>/dev/null)" +[ -n "${userns_max}" ] && info "user.max_user_namespaces: ${userns_max}" + +clone_knob="$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null)" +[ -n "${clone_knob}" ] && info "unprivileged_userns_clone: ${clone_knob}" + +if [ -r /proc/self/mountinfo ]; then + info "mount at ${MOUNT_POINT}:" + awk -v m="${MOUNT_POINT}" '$5 == m {print " " $0}' /proc/self/mountinfo 2>/dev/null | head -3 +fi +echo + +# --------------------------------------------------------------- +# Verdict +# --------------------------------------------------------------- +echo "verdict" +if [ "${a_refused}" = "1" ]; then + echo " USERNS — preventive enforcement is available with no special privileges." + echo " Wrap a read-only exec in: unshare -Urm sh -c 'mount --bind -o ro ... && '" + exit 0 +elif [ "${b_refused}" = "1" ]; then + echo " CAP_SYS_ADMIN — preventive enforcement works, but only while that" + echo " capability is granted. Confirm it is guaranteed before depending on it;" + echo " a capability that silently disappears would turn enforcement off." + exit 0 +else + echo " UNAVAILABLE — the container cannot build a private read-only view." + echo " Do not reach for a fuse-native fork to work around this: attributing a" + echo " FUSE request to an exec is racy, and a capability that is only usually" + echo " enforced is worse than an honest refusal on write-back. Keep the" + echo " after-the-fact refusal and make it louder instead." + exit 1 +fi diff --git a/script/set-versions.mjs b/script/set-versions.mjs index ae9a8f4c..2b9fafd8 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 computerd image. const DOCKERFILES = [ + "examples/agent/Dockerfile", "examples/container/Dockerfile", "examples/think/Dockerfile", "examples/think-compare-runtimes/Dockerfile.workspace",