Skip to content

Commit eebd8b1

Browse files
author
Antoni T
committed
docs: specify write access and the approval seams
A new chapter for per-command write access, the gate consulted before an action, and the audit hook notified after it, plus the surface changes in the runtime and tool chapters and a section in the package README. The chapter leads with why classifying commands is not the thing being attempted. Correct classification is not achievable; making a wrong classification safe is. That framing is what explains the rest of the design, including why the capability is per command rather than per write and why a gate cannot ask a human once the command is running. The per-backend enforcement table is the part worth reading twice. What `writable: false` costs a command is not the same everywhere: backends sharing the host store refuse the write where it happens, and a container writes to its own copy first and has the change refused on the way back. The second is weaker and leaves the two copies disagreeing. Documenting that plainly is better than a sentence implying the flag prevents writes everywhere. The `EROFS` row in the filesystem chapter said no code path throws it. One does now, so it describes the two cases that reach it.
1 parent a0d71ac commit eebd8b1

6 files changed

Lines changed: 270 additions & 3 deletions

File tree

docs/04_filesystem_interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ so handlers from Node code port over directly.
282282
| `EPERM` | Operation is forbidden, e.g. deleting the workspace root. |
283283
| `EIO` | Backing storage failed unexpectedly. |
284284
| `EACCES` | *Reserved for future mount layer (see [06. Mount Interface](./06_mount_interface.md)).* No code path in `workspace-fs` currently throws it. |
285-
| `EROFS` | *Reserved for future mount layer (see [06. Mount Interface](./06_mount_interface.md)).* No code path in `workspace-fs` currently throws it. |
285+
| `EROFS` | The write was refused. Either the path is under a read-only mount root (see [06. Mount Interface](./06_mount_interface.md)), or the filesystem handle has no write access because the command holding it is running read-only (see [20. Write access and approval](./20_approval.md)). |
286286

287287
### Example: handle "file missing" and bubble everything else
288288

docs/05_runtime_interface.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface WorkspaceRuntimeExecOptions {
3333
timeoutMs?: number;
3434
env?: Record<string, string>;
3535
stdin?: Uint8Array | string;
36+
writable?: boolean;
3637
}
3738

3839
interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEvent> {
@@ -88,6 +89,10 @@ await workspace.runtime.exec(
8889

8990
Omitting `backend` selects the first configured backend. Backend selection is routing, not authorization; public gateways must validate it against server-side policy.
9091

92+
## Write access
93+
94+
`writable` defaults to true. Pass `false` for a command expected only to read, and a write it attempts fails rather than lands. What that costs the command depends on the backend: `worker-shell` and `worker-javascript` share the host store and refuse the write where it happens, while a Container writes to its own copy and has the change refused on the way back, reported in `skipped` with reason `no-write-access`. A configured gate can also withdraw write access from a command that asked for it. See [20. Write access and approval](./20_approval.md).
95+
9196
## Command synchronization
9297

9398
Command backends continue to use the existing synchronization bracket:

docs/09_tool_interface.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ createExecTool({
204204
backends,
205205
defaultBackend,
206206
maxBytes?,
207+
writable?,
207208
});
208209
```
209210

@@ -212,6 +213,7 @@ createExecTool({
212213
| `backends` | required | Map of backend id to a model-facing description. |
213214
| `defaultBackend` | required | Backend used when the model omits `backend`. Must be a key in `backends`. |
214215
| `maxBytes` | 64 KiB | UTF-8 byte cap for each of stdout and stderr. |
216+
| `writable` | every command may write | `({ command, cwd, backend }) => boolean`. Decides whether a command may modify the workspace. |
215217

216218
Schema:
217219

@@ -223,22 +225,25 @@ Schema:
223225
}
224226
```
225227

226-
Calls `workspace.runtime.exec(command, { cwd, encoding: "utf8", backend })`, waits for `result()`, and returns:
228+
Calls `workspace.runtime.exec(command, { cwd, encoding: "utf8", backend, writable })`, waits for `result()`, and returns:
227229

228230
```ts
229231
{
230232
command: string;
231233
cwd: string | null;
232234
backend: string;
235+
writable: boolean;
233236
exitCode: number;
234237
stdout: string;
235238
stderr: string;
236239
}
237240
```
238241

242+
`writable` is resolved by the host, not by the model, and is deliberately absent from the schema. The case it defends against is the command mislabelled as read-only, so a label the model supplies would agree with the mistake. It is reported back on the result so the model can tell a refused write from a broken command instead of retrying the same thing. A gate refusal arrives as an `error` field rather than a thrown error, so the agent loop survives it. See [20. Write access and approval](./20_approval.md).
243+
239244
`exec` is opt-in. `createAITools()` includes it only when the caller passes `shell` options and `readonly` is not true. The backend descriptions are included in the tool description so the model can choose the cheapest backend that can run the command.
240245

241-
Wire this tool up carefully: it executes arbitrary shell commands inside the configured backend. Use `readonly: true` for inspection-only agents, or omit `shell` when command execution is not part of the agent's job.
246+
Wire this tool up carefully: it executes arbitrary shell commands inside the configured backend. Use `readonly: true` for inspection-only agents, or omit `shell` when command execution is not part of the agent's job. A `writable` resolver narrows what a command can do but is not a substitute for either: it stops a command classified read-only from writing, and does nothing about one classified writable.
242247

243248
## `publish`
244249

docs/20_approval.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# 20. Write access and approval
2+
3+
Two related things: a per-command write capability, and a pair of hooks
4+
for deciding and recording what a workspace is asked to do.
5+
6+
## The problem
7+
8+
An agent decides a command is read-only and runs it. Sometimes it is
9+
wrong — an alias, a shell function, a `&&` it did not parse, a script
10+
that writes a lockfile on the way to printing a version. The workspace
11+
is modified, nothing reports it, and the mistake is found later by
12+
whatever breaks next.
13+
14+
Classifying commands correctly is not solvable. What is solvable is
15+
making the classification safe to get wrong: a command believed to be
16+
read-only runs without write access, so if the belief was wrong the
17+
write fails instead of landing.
18+
19+
## Per-command write access
20+
21+
`writable` on an exec call. Defaults to true.
22+
23+
```ts
24+
const handle = await workspace.runtime.exec("git log --oneline", {
25+
writable: false,
26+
});
27+
```
28+
29+
The capability is per command, not per write. `rm -rf` issues one call
30+
per entry; denying partway through leaves a half-deleted tree. A
31+
command is the smallest unit that can be refused and leave the
32+
workspace in a state the caller can reason about.
33+
34+
It travels with the command rather than being read from configuration,
35+
because commands overlap. A read-only command running beside a writable
36+
one must not be able to disarm it, and must not be able to borrow its
37+
access.
38+
39+
### What "no write access" enforces, per backend
40+
41+
Not the same thing everywhere, and the difference matters when
42+
choosing a backend for work you intend to constrain.
43+
44+
| Backend | Enforcement | Effect of a write |
45+
|---|---|---|
46+
| `worker-shell` | Preventive | Fails inside the command with `EROFS`. Nothing is written. |
47+
| `worker-javascript` | Preventive | The capability handed to the module is narrowed, and the guest shim throws the refusal inside the module. |
48+
| `container-shell` | After the fact | Lands in the container's own copy, then is refused on the way back and reported in `skipped`. |
49+
50+
`worker-shell` shares the host store, so a command that runs there
51+
holds a filesystem handle built without the capability, and the first
52+
write fails where it happens. The command sees an ordinary filesystem
53+
error and reports it like any other.
54+
55+
For `worker-javascript` the per-execution flag is intersected with the
56+
backend's own `access` option rather than replacing it. A backend
57+
registered `access: "read"` stays read-only however the call was made,
58+
and an execution asking to be read-only gets that on a read-write
59+
backend. Neither side widens the other, which is the same rule the gate
60+
follows.
61+
62+
A container has its own copy of the files. By the time the host hears
63+
about a change the container has already written it, so there is
64+
nothing left to prevent — only to refuse. The changes are dropped on
65+
arrival and listed in `skipped` with reason `no-write-access`:
66+
67+
```ts
68+
const result = await handle.result();
69+
for (const entry of result.skipped) {
70+
if (entry.reason === "no-write-access") {
71+
// The command wrote this. It was not applied.
72+
}
73+
}
74+
```
75+
76+
Refusing is discarding, not deferring. A refused change is not
77+
redelivered to the next pull that does have write access, or the
78+
refusal would only be a delay. The consequence is that the container's
79+
copy and the workspace disagree from that point on: the container still
80+
holds the file it wrote. Treat a refused write there as a reason to
81+
discard the container rather than keep using it.
82+
83+
## The gate
84+
85+
Consulted before an action, and able to refuse it.
86+
87+
```ts
88+
new Workspace({
89+
storage: ctx.storage,
90+
gate: {
91+
async check(action) {
92+
if (action.kind !== "shell.exec") return { allow: true };
93+
if (isDestructive(action.command)) {
94+
return { allow: false, reason: "destructive commands need review" };
95+
}
96+
return { allow: true };
97+
},
98+
},
99+
});
100+
```
101+
102+
A refusal throws `ActionDeniedError`, which carries the action and the
103+
reason. Nothing runs.
104+
105+
A gate may also allow an action with write access withdrawn, which is
106+
the answer for a command a policy will run but not trust:
107+
108+
```ts
109+
return { allow: true, writable: false };
110+
```
111+
112+
Narrowing only. A gate cannot grant access an action did not ask for,
113+
so a read-only exec stays read-only regardless of what the gate
114+
returns.
115+
116+
`check` may be async, and the action does not start until it settles —
117+
long enough to consult a policy service or wait for a human. Whatever
118+
it waits on holds up the caller.
119+
120+
A gate that throws propagates. A gate that could not reach a decision
121+
is not a gate that said no, and code that cannot tell those apart will
122+
eventually treat an outage as permission.
123+
124+
### What is gated
125+
126+
`shell.exec`, once per command, and the mutating methods on
127+
`Workspace.fs``writeFile`, `mkdir`, `rm`, `chmod`, `symlink` — once
128+
per call. Reads are not gated.
129+
130+
The filesystem half is there because `Workspace.fs` writes to the store
131+
without crossing the wire. A gate covering only `shell.exec` would have
132+
an obvious way around it: deny the command, write the file directly.
133+
134+
Filesystem calls are gated individually because each call is the whole
135+
action, so refusing one leaves nothing half-finished. That is the
136+
difference from a command, and the reason a command is gated once.
137+
138+
A gate should return `{ allow: true }` for action kinds it does not
139+
recognise, so kinds added later stay permitted rather than being
140+
refused by a gate that was never asked about them.
141+
142+
### Asking a human
143+
144+
Ask before the command starts. Once it is running there is nowhere to
145+
suspend it that does not risk a partial result, and a write-by-write
146+
prompt would ask hundreds of times for one `rm -rf`.
147+
148+
## The audit hook
149+
150+
Notified after an action has been decided, and after it has run.
151+
152+
```ts
153+
new Workspace({
154+
storage: ctx.storage,
155+
audit: {
156+
record(action, outcome) {
157+
log({ kind: action.kind, status: outcome.status });
158+
},
159+
},
160+
});
161+
```
162+
163+
`outcome.status` is `allowed`, `denied`, or `failed`. Refused actions
164+
are reported too, and are usually the more interesting half.
165+
166+
It cannot deny anything, and errors it throws are swallowed. By the
167+
time it runs the action has already happened; failing the caller over a
168+
failed log entry would make an audit hook into a gate.
169+
170+
For `shell.exec` it fires on the spawn, not on the exit. `exec` returns
171+
a detached handle the caller may never drain, so there is no later
172+
moment guaranteed to arrive, and picking one would mean a command that
173+
is dropped is never recorded at all. What the command went on to do is
174+
on the observer's span and on the result.
175+
176+
## Why this is not the observer
177+
178+
The observer in
179+
[11. Lifecycle](./11_lifecycle.md) must return its callback's result
180+
unchanged — observability that changes behaviour is a bug. A gate
181+
exists to change behaviour. They are separate seams with the same shape
182+
and opposite licences, rather than one seam with a weakened contract.
183+
184+
## Tool layer
185+
186+
`createExecTool` takes a `writable` resolver. It is not part of the
187+
tool's input schema, so the model cannot set it:
188+
189+
```ts
190+
createExecTool({
191+
workspace,
192+
backends,
193+
defaultBackend: "worker-shell",
194+
writable: ({ command }) => !readOnlyCommand(command),
195+
});
196+
```
197+
198+
The model must not classify its own command. The case being defended
199+
against is the command mislabelled as read-only, and asking the model
200+
that mislabelled it to declare the label produces a flag that agrees
201+
with the mistake. The host decides from something it already trusts,
202+
and the model finds out by the write failing.
203+
204+
The effective access is reported on the tool result, so a model can
205+
tell a refused write from a broken command instead of retrying the same
206+
thing. A gate refusal comes back as a tool result too, not a thrown
207+
error, so the agent loop survives it.
208+
209+
## Related
210+
211+
- [04. Filesystem interface](./04_filesystem_interface.md)`EROFS`
212+
and the filesystem surface.
213+
- [05. Runtime interface](./05_runtime_interface.md) — exec options and
214+
the synchronization bracket.
215+
- [06. Mount interface](./06_mount_interface.md) — read-only mounts,
216+
which are a fixed property of a path rather than a per-command
217+
decision. Both apply, and neither is a way around the other.
218+
- [09. Tool interface](./09_tool_interface.md) — the agent-facing tools.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ above, then dive into the area you're working on.
243243
| [17. Isolate JavaScript runtime](./17_isolate_javascript.md) | ECMAScript modules, durable imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed lifecycle. |
244244
| [18. Runtime migration](./18_runtime_migration.md) | Breaking preview-API mappings from public shell and script-execution surfaces to `workspace.runtime`. |
245245
| [19. Performance](./19_performance.md) | Filesystem benchmarks: `fs-bench` numbers, an `npm install` comparison, and how to reproduce them. |
246+
| [20. Write access and approval](./20_approval.md) | Per-command write access, the gate consulted before an action, and the audit hook notified after it. |
246247

247248
## High-level API
248249

packages/computer/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,44 @@ default is a zero-cost no-op, so there's no overhead unless you opt in.
470470
An adapter for the Cloudflare runtime lives at
471471
`@cloudflare/computer/observe/cloudflare`.
472472

473+
### Write access and approval
474+
475+
Pass `writable: false` to an exec call for a command you expect to only
476+
read. A write it attempts then fails instead of landing, which is what
477+
makes a wrong guess about a command safe:
478+
479+
```ts
480+
await workspace.runtime.exec("git log --oneline", { writable: false });
481+
```
482+
483+
`worker-shell` and `worker-javascript` share the host store, so the
484+
write fails inside the command. A container has its own copy and writes
485+
there first, so the change is refused on the way back and reported in
486+
`result.skipped` with reason `no-write-access`.
487+
488+
Pass a `gate` to be consulted before each command and each mutating
489+
`workspace.fs` call, with the option to refuse it or to withdraw its
490+
write access, and an `audit` hook to be told what was decided:
491+
492+
```ts
493+
new Workspace({
494+
storage: ctx.storage,
495+
gate: {
496+
check(action) {
497+
if (action.kind === "shell.exec" && isDestructive(action.command)) {
498+
return { allow: false, reason: "needs review" };
499+
}
500+
return { allow: true };
501+
},
502+
},
503+
audit: { record: (action, outcome) => log(action, outcome) },
504+
});
505+
```
506+
507+
Both default to no-ops. They are separate from `observer` because an
508+
observer must not change what happens and a gate exists to. See
509+
[20. Write access and approval](../../docs/20_approval.md).
510+
473511
## Examples
474512

475513
- [`examples/worker-shell`](../../examples/worker-shell) — the

0 commit comments

Comments
 (0)