Add a write capability for a single command, plus gate and audit seams - #43
Add a write capability for a single command, plus gate and audit seams#43AntoniTok wants to merge 13 commits into
Conversation
Until now the only thing that could refuse a write was a registered read-only mount root, which is a property of the stored tree. Add a second dimension that is a property of the handle instead: build a `WorkspaceFilesystem` or `SQLiteWorkspaceProvider` with `writable: false` and every mutating method on it reports EROFS. The capability is fixed at construction and cannot be changed, which is the point. A command is not one write. `rm -rf` on a populated tree issues hundreds of calls, so a decision taken per write would let the first forty land before the forty-first is refused, leaving a half-deleted tree behind. A capability that holds for as long as the handle exists cannot produce that state. It lives on the handle rather than on the `Database` because two commands can be in flight against one workspace and need not agree about write access. `resolveCache` documents that exactly one `Database` wraps each `SqlStorage`, so a second handle is not available as a place to put this, and a flag on the shared one would let a read-only command disarm a writable command running beside it. Each check now sits at the layer that owns its state. The mount mode stays next to the tree, where the sync apply path still sees it. The capability sits on the handle's own methods, where the flag is immutable and cannot be flipped between the check and the commit. Four provider methods reached SQLite without consulting any guard: `chmodSync`, `openWriteBufferSync`, `openWriteBufferForCreateSync`, and `releaseWriteBufferSync`. Under mount-root enforcement alone they were narrow. A whole-handle capability makes them the obvious way out, since a command driven through a FUSE mount reaches all four, so they are guarded too. A read-only provider also refuses to open a file for writing at all, the way opening for write on a read-only filesystem fails at the open rather than at the first write. `WorkspaceFilesystem.writeFile` becomes async. It returns a promise, so a guard that threw synchronously would escape a caller using `.catch()` rather than arriving as a rejection.
`applyChanges` and `applyChangesSync` take a `writable` option. With `writable: false` nothing is applied and every entry comes back in `skipped` with reason `no-write-access`. This is the second half of running one command without write access. A backend that writes this store directly is stopped before anything commits, by a filesystem handle built without the capability. A backend that keeps its own copy of the files cannot be stopped that way: it has already written to its own copy by the time the changes arrive, so they stop on the way in instead. That is after the fact rather than preventive, and the two copies diverge as a result. The skipped entries are what tells the caller so, rather than hiding it. The capability is checked ahead of the idempotence skip, so a read-only apply reports every entry it was handed instead of quietly dropping the ones that happened to match local state. It is also checked ahead of the mount-root guard, so an entry both would refuse reports the missing capability, which is the answer the caller asked for. `SkippedEntry` becomes a union discriminated on `reason`. The two refusals carry different information: a read-only mount names the mount that owns the path, and a missing capability has no mount to name because the apply had no write access wherever the entry pointed. Code that read `mountRoot` unconditionally no longer compiles, which is the intent for a field that is now conditional.
`ShellRPC.exec` takes a `writable` flag and `pullOnce` takes a matching option that it forwards to `applyChanges`. The flag on `exec` is what a caller sets; the option on `pullOnce` is what enforces it for a runner that keeps its own copy of the files. That runner writes to its copy before we hear about the change, so the refusal happens as the change comes back rather than at the write. The interface comment says so plainly instead of implying the flag prevents the write everywhere. Two details that are easy to get wrong in the pull path: The object fetch is skipped without write access. `stageBlob` writes to the local store directly, so fetching bytes for entries about to be refused would write through the missing capability, and pay for the transfer first. The fetch cursor still advances over refused entries. Refusing is discarding, not deferring. Holding the cursor back would hand the same writes to the next pull that does have write access, which would make the refusal a delay rather than a denial. The stream is drained either way, so the caller gets a full account of what was refused rather than a bare count.
Two hooks around a workspace action: `gate.check(action)` runs before it and can refuse it, `audit.record(action, outcome)` runs after and records what happened. Both default to no-ops. `WorkspaceOptions` takes them as `gate` and `audit`. These are deliberately not folded into the existing observer. That contract says an implementation must return the callback's result unchanged so the wrapping is invisible — observability that changes behaviour is a bug. A gate is the opposite, so it gets its own seam with the same shape and the opposite licence, rather than the observer contract being weakened to fit. The two hooks differ in what they can do, matching what they are for. The gate decides, runs first, and sees only the request. The audit records, runs last, and sees the outcome. An audit hook cannot deny anything and its errors are swallowed, because by the time it runs the action has already happened; the alternative is a failed log entry failing the caller's work. A gate may also allow an action with write access withdrawn, which is the answer a policy wants for a command it will run but not trust. Narrowing only: a decision cannot hand out access the action never asked for, or a read-only exec would depend on the gate behaving. A gate that throws propagates rather than being read as a refusal. A gate that could not reach a decision is not a gate that said no, and a caller that cannot distinguish them will eventually treat an outage as permission. `Workspace.fs` is gated too, via a `WorkspaceFilesystem` subclass. That surface writes to the local store without crossing the wire, so gating only `shell.exec` would leave an obvious way around it: deny the command, write the file directly. Reads stay ungated. It is a subclass rather than a wrapper so the handle is still a WorkspaceFilesystem for the mount and think surfaces that take one by type. Filesystem mutations are gated per call, where each call is the whole action. Commands are gated once for the command, and gate.ts explains why: `rm -rf` is hundreds of calls, so refusing partway leaves a half-deleted tree, and there is no safe place to suspend a running command to ask a human.
`ExecOptions.writable` and `WorkspaceRuntimeExecOptions.writable`, defaulting to true. The flag reaches the runner over RPC and travels with the command's own post-command pull, and `Workspace.pull` takes a matching option. The pull is the part that is easy to leave out and useless to omit. The bracket around a command pulls whatever the command produced; a read-only command whose pull still had write access would have exactly its unauthorised changes applied a moment after it finished. The flag travels with the command rather than being read from configuration so that overlapping commands cannot borrow each other's access. `shell.exec` is also where the gate is consulted, before the pre-exec push, so a refused command moves no data either. The audit hook fires on the spawn rather than on the exit. exec() returns a detached handle that the caller may never drain, so there is no later moment guaranteed to arrive; picking one would mean a command that is dropped is never audited. What the command went on to do is already on the observer's span and on ExecResult. The routed shell now forwards the backend id it resolved instead of dropping the caller's selector. A gate deciding whether to trust a command with write access wants to know which backend runs it, because that determines whether a refused write is prevented or only reported.
This is where `writable: false` stops being a request and starts
preventing writes. The backend forwards the flag to the shell, and the
shell asks the host for a workspace stub built without write access
rather than checking the flag itself.
Asking for a stub it cannot write through is the point. Every route
into the workspace for that command — the shell's builtins, the git
command, anything a subclass registers — goes through that one handle,
so all of them are refused together. A check at the call site would
have covered that call site and nothing else, and would have needed
repeating for every path added later.
`Workspace.stub({ writable: false })` is the seam, backed by
`fsWithAccess`, which hands out a second filesystem handle over the
same store. Two handles over one database is deliberate: commands
overlap, and a read-only one must not be able to disarm a writable one
running beside it. The capability cannot live on the database for that
reason, and cannot be a second Database at all — dofs assumes exactly
one Database wraps each SqlStorage.
The stub narrows its runtime too. A read-only stub that could still
spawn a writing command would not be read-only.
The tests run real just-bash against a real Workspace, through the
same `workspace.stub(options)` call production makes. The one that
matters is `find /workspace -mindepth 1 -delete` under
`writable: false`: every file survives. Its control runs the same
command with access and confirms the tree does get deleted, so the
first test cannot pass by the command being broken. A third asserts
the failure reaches stderr as a read-only error, so neither can pass
because the stub failed to load.
No `denied` list on ExecResult. Here the refusal is already visible
where it happened — the write fails inside the command, which sees the
error and reports it — and for a backend with its own copy of the
files it arrives in `skipped`. A third channel would duplicate the one
and need new wire surface for the other.
`ExecToolOptions.writable` is a host-supplied function that decides
whether a command may modify the workspace. Omitting it leaves every
command writable, which is the behaviour without it.
It is not part of the input schema, and that is the whole design. The
model must not classify its own command: the case this feature exists
for is the command mislabelled as read-only, and asking the model that
mislabelled it to declare the label would produce a flag that agrees
with the mistake every time. The host decides from something it
already trusts — an allowlist, a plan step, a human — and the model
finds out the same way it finds out about any other failure, by the
write failing. A test asserts the field stays out of the schema, since
adding it there would look like a convenience.
The resolver is allowed to be wrong, and fails safe when it is. A read
command classified read-only runs normally. A write command classified
read-only fails where it writes instead of writing.
The effective access is reported on the tool result. Without it a
read-only run looks like an arbitrary failure and the model's next move
is to run the same command again.
A gate denial arrives as a thrown error and is returned as a tool
result like every other failure, so the model can read the refusal
rather than the agent loop tearing down.
`createAITools({ readonly: true })` is left alone. It drops the exec
tool even when a shell is configured, and a test passes `shell`
specifically to assert that, so the behaviour is a decision rather
than an oversight. Letting a readonly toolset run read-only commands
is worth proposing separately; it should not ride along here.
`ModuleExecutionInput.writable` narrows the capability handed to a module execution. Intersected with the backend's configured `access` rather than replacing it. A backend registered read-only stays read-only however the call was made, and an execution asking to be read-only gets that on a read-write backend. Neither side can widen the other, which is the same rule the gate follows. Like the shell backend, this one shares the host store, so the refusal lands where the write is attempted. The bridge answers the refused capability call with an error payload and the generated guest shim throws it inside the module, so the module sees an ordinary filesystem error rather than a silent no-op. The tests assert the file does not appear, with a control that runs the same module with access and confirms it does. A third pins the intersection by asking a read-only backend for a writable execution.
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.
commit: |
ExecToolOptions.writable is a function of ExecToolInput, and ExecToolOptions.workspace is an ExecWorkspaceLike, but neither type left the package. A host writing the resolver the previous commit added had no way to name its argument, which is the one thing it has to do to use the option at all. Found by writing that resolver outside the package for the first time.
The FUSE driver's errno table had no entry for EROFS, so toErrno fell through to its EIO default. Two separate refusals reach it. A read-only mount root rejects writes at the dofs data layer, which is where changes arriving from a container through the sync path are caught, and a filesystem handle built without the write capability rejects every mutation for as long as it exists. Both raise EROFS, and both reached the guest as an input/output error. The distinction matters to whoever reads the message. EROFS is a fact about the workspace the caller can act on: this tree does not take writes. EIO says the daemon or the disk is broken, which invites a retry that fails the same way every time. An agent reading the error suffers most, since it will go looking for a way around a fault that has none. EACCES was missing for the same reason and is added alongside it.
A backend holding its own copy of the files cannot be stopped from writing when the command has no write access. It writes, exits zero, and the changes are dropped when they are pulled back. ExecResult has carried those entries in skipped since the capability landed, but the exec tool never passed them on, so a caller saw a clean success and a model reported work that never happened. Silence is the wrong answer for the one backend where the refusal arrives late. The tool now returns discardedWrites when the pull refused anything: how many entries, why, and a capped sample of the paths. The reason separates a command that ran without the capability from one that reached into a read-only mount root, because the two call for different responses and one word for both would hide that. The paths are capped at ten while the count stays honest, since one refused entry per file means a recursive change can produce thousands. The key is absent rather than null when nothing was refused. Every key in a tool result is context a model pays for on every call, and nothing refused is the usual case.
|
The per-backend table reads like three equal options, and one row is weaker than the other two on purpose. Worth stating plainly. The two worker backends hold no files. A command without write access is handed a filesystem handle built without the capability, so the write fails where it happens and nothing lands. A container has its own copy: it writes, exits zero, and the change is refused only when it is pulled back. The refusal costs a file that was already written and leaves the container's copy disagreeing with the workspace from that point on, which is why the chapter says to throw the container away rather than keep using it. Discarding a warm container because one command tried a write it was not allowed is a bad trade, and the sort of thing people work around by granting write access they did not want to grant. There is a candidate fix, the same shape as the two filesystem handles over one store. Linux can mount a directory a second time at the same path with writes refused, and a private mount view gives one process its own copy of the mounts. Wrap a command that has no write access in both and the kernel refuses the write before it reaches a file: nothing lands, so there is nothing to refuse afterwards, and the two copies never disagree. The capability lives on the mount view instead of a filesystem handle, but the property that matters carries over — the view belongs to one process, so a read-only command still cannot disarm a writable one beside it. Whether a container may create a mount view at all is a property of the runtime, not of this repository, so it needs measuring first. This is a separate change: it is blocked on that measurement, it touches the process spawn path in One thing worth flagging for whoever picks it up. A read-only mount is scoped to a path; a filesystem handle is scoped to the whole store. So this would make something the current design cannot express: source read-only, build output and |
SkippedEntry became a union of two reasons when the per-command write capability landed: an entry can be refused because it targets a read-only mount root, or because the command ran without write access and the post-command pull refused everything it was offered. The comments on ExecResult.skipped and Workspace.pull still described only the mount case, and ExecResult went further and claimed the field is empty when no read-only mounts are registered. That is wrong for a container command that ran read-only. Describe both reasons, and note that a backend sharing the workspace store never fills the field because it has no pull to refuse.
The goal was to make it so a wrong guess about whether a command writes harmless, by giving the command no write access rather than trying to classify it correctly.
Three things were introduced to do this.
First, a
writableflag on an exec call. Run a command with write access switched off and any write it tries fails with a read-only error. That is the same error a read-only mount already gives. The default stays true, so nothing existing changes. The flag is set once for the whole command, not per write. Otherwiserm -rfgets denied on file 47 after 46 are already gone.Second, a
gatehook. It runs just before an action, either a command or a file write. It sees what command, what path, and which backend, so it has enough to decide. It can refuse the action, or allow it and take write access away. If it refuses, nothing runs.Third, an
audithook. It runs after the action and is told what happened. It cannot block anything. It only records.Both hooks are optional. Pass neither and nothing changes.
The problem the first of these solves is when an agent decides a command only reads, and it is wrong. The workspace gets modified, nothing reports it, and the mistake surfaces later as whatever breaks next. Classifying commands correctly is not solvable. What is solvable is making a wrong classification safe to hold. A command believed to only read runs without write access, so a write it attempts fails instead of landing.
The flow below shows the whole picture once this is wired into an agent with human approval and a resolver that decides read from write. That is the setup #44 builds. This change is the boxes under
this change. The greyed out boxes are the surrounding system, shown so the ordering is clear.flowchart TD M["model emits exec('rm -rf /workspace')"] subgraph SURROUND["surrounding system, see #44, not this change"] direction TB TA["tool approval<br/>needs a human? turn pauses until you allow it"] WR["writable resolver<br/>asks for write access"] end subgraph THIS["this change"] direction TB G{"gate.check(action)<br/>allow or refuse?"} B["backend makes the filesystem handle<br/>writable, or one that cannot write<br/>(the gate may have taken write access away)"] A["audit.record 'allowed'"] RUN["command runs, output streams back live"] EXIT["command exits, stream finished"] ASK{"container only,<br/>the workspace asks the backend what it changed"} SAVED["changes saved"] TOSSED["changes thrown away,<br/>listed in result.skipped"] DENIED["audit.record 'denied'"] STOP["no command runs"] end R["result to model, always, on any of the paths<br/>it carries writable, and a refusal arrives as an<br/>error field rather than a crash, so 'not allowed'<br/>never reads as 'broken, try it again'"] M --> TA --> WR --> G G -->|allow| B --> A --> RUN --> EXIT --> ASK G -->|refuse| DENIED --> STOP --> R ASK -->|"writable true"| SAVED --> R ASK -->|"writable false"| TOSSED --> R classDef surround fill:#f5f5f5,stroke:#bbb,color:#555,stroke-dasharray:4 3; class TA,WR surround;What the flag actually prevents depends on where the files live. That matters when you pick a backend for work you want to limit.
worker-shellworker-javascriptcontainer-shellskipped.The two worker backends share the workspace store, so a write fails the moment the command tries it. A container is a separate machine with its own copy of the files. Nothing can stop it writing to that copy. Its output streams back while it runs. When the command exits and that stream has finished, the workspace asks the container what it changed. With write access the answer is saved. Without it the answer is thrown away and listed in
result.skipped. That is weaker. The file was already written on the container side, so its copy and the workspace now disagree. Throw the container away rather than keep using it.The flag sits on the filesystem handle rather than on the store underneath. Commands overlap, and a read-only one must not be able to disarm a writable one running next to it, or borrow its access.
A gate can take write access away but never hand it out. Every combination works out as follows.
writable: true{ allow: true }writable: true{ allow: true, writable: false }writable: false{ allow: true }writable: false{ allow: true, writable: true }{ allow: false }The fourth row is the one worth checking. A gate asking for more access than the caller wanted is capped, not honored. A command sent read-only stays read-only whatever any policy says about it. #44 uses this to tie write access to human approval, so a command gets it only if somebody said yes.
A gate that throws passes the error up rather than counting as a refusal. A gate that could not reach a decision is not a gate that said no, and code that cannot tell those apart will treat an outage as permission. The hooks look like the existing observer but are kept separate from it. An observer must return its result unchanged, and a gate exists to change what happens.
At the tool layer the host supplies a resolver that decides whether a command may write. It is left out of the schema the model fills in on purpose. The case being guarded against is the command the model got wrong, so a label from that same model would agree with the mistake every time.
The access used comes back on the result, and the reason is to stop a retry loop. A refused write and a broken command look the same to a model. Both are just a failure, and the obvious answer to a failure is to run it again, which fails the same way, forever. Telling the model the write was never allowed breaks that cycle. It can report the problem or ask for access instead of trying a fourth time. A gate refusal comes back the same way, as an error field on the tool result rather than a thrown error, so a denied command tells the model why instead of killing the agent loop.
You can check the core behavior yourself. This command tries to empty the workspace and is stopped.
That case is a test. A control runs the same command with write access and confirms the tree does get deleted, so the first test cannot pass just because the command is broken. A third pins the failure to a read-only error, so it cannot pass because the workspace handle failed to load. The same pair covers the module backend. Other tests cover two handles over one store disagreeing about write access, a read-only pull applying nothing and staging no bytes, and the flag staying out of the tool schema. Run them with
npm test --workspace @cloudflare/computerandnpm test --workspace @cloudflare/dofs.docs/20_approval.mdis new. It covers the flag, both hooks, and what each backend enforces. The runtime, filesystem, and tool chapters pick up the surface changes. The read-only row in the filesystem chapter no longer claims nothing throws it.The container backend should stop writes up front rather than after the fact. There is a candidate. Mount the workspace a second time with writes refused, inside a mount view private to the command, so the kernel refuses the write before it reaches a file. Whether a container can make its own mount view depends on the runtime rather than on this repository, so #44 measures that first. Separately,
createAITools({ readonly: true })still drops the exec tool even when a shell is set up. Letting a read-only toolset run read-only commands is now possible and looks worth doing, but an existing test checks the current behavior on purpose, so it belongs in its own change.Why the gate also covers direct
workspace.fswrites (not core to the change)The gate covers
workspace.fswrites as well as commands, which is more than was asked for. Those calls write to the store without crossing the wire. A gate over commands alone would have an obvious way around it. Deny the command, then write the file directly. The cost is that this closes the direct filesystem surface for every caller, not just agents, so it is worth disagreeing with if you see it differently.Two reporting fixes that came out of the same work (not core to the change)
The mount driver turned a refused write into a generic input output error. That is the worst thing to tell an agent. It reads as a broken daemon and invites a retry that fails the same way, so the agent goes looking for a way around a fault that has none. Refusals now reach the caller as a read-only error, which is a fact it can act on.
Separately, the exec tool dropped refused writes entirely. They have been on the result since the flag landed, but the tool never passed them on, so a caller saw a clean success and a model reported work that never happened. It now returns
discardedWriteswith how many, why, and a capped sample of paths.