The two tools
Two tools mutate the filesystem:
write— full-file overwrite.internal/tools/builtin/write.gowrites with mode0o644andMkdirAll(dir, 0o755).edit— single exact-string replacement inside an existing file.internal/tools/builtin/edit.go.
Both require an absolute path (they call filepath.IsAbs). Both perform no atomic-swap dance — they use os.WriteFile directly.
The container view of the world
The reference Quadlet unit at docker/rousseau-agent.container mounts three host directories into the container:
Volume=%h/.local/share/rousseau:/home/rousseau/.local/share/rousseau:rw,Z
Volume=%h/.claude:/home/rousseau/.claude:rw,Z
Volume=%h/team-rousseau-workspace:/workspace:rw,Z
Nothing else on the host is visible. From inside the container, an edit tool call against /workspace/repos/foo/main.go resolves to ~/team-rousseau-workspace/repos/foo/main.go on the host.
:Z — the SELinux label
The :Z flag on each Volume= tells Podman to relabel the mount with a container-private SELinux MCS category. Without it, on a system with SELinux in enforcing mode:
- Reads still work most of the time (
container_file_tis broadly readable). - Writes fail with
EACCESandavc: denied { write }in the audit log.
If you swap the flag for :z (lowercase), Podman relabels with a shared category — safer for hosts you share between multiple container users, but not the default.
On systems without SELinux (Debian, non-hardened Ubuntu), :Z is a silent no-op.
UserNS=keep-id — UID mapping
The container runs as UID/GID 1000. Without user-namespace mapping, rootless Podman would remap 1000 into the subuid range (typically 100000+), and files written from inside the container would be owned by that mapped UID on the host — unusable for the operator.
UserNS=keep-id maps container UID 1000 to the host user's UID (also 1000 in the reference setup). Files written inside /workspace end up owned by seb:seb on the host — exactly what you want.
If your host user is not UID 1000, the mapping still works; keep-id uses the invoking user's actual UID.
Editing outside /workspace
Because the bind mounts are the container's only view of the host filesystem, write or edit against /etc/nginx/nginx.conf will fail with a path-not-found error — the path simply doesn't exist inside the container. This is a feature: it means the operator's approver policy can trust the container boundary.
If you genuinely need the daemon to touch a different host path:
- Preferred: add a new
Volume=line to the Quadlet unit. Make the least-permissive choice::rofor read-only,:Zfor private SELinux labelling. - Do not run rousseau outside the container to bypass the boundary — you lose seccomp, drop-caps, and the read-only root filesystem.
Editing outside the container
If you run rousseau directly on the host (no container), the tools operate against the daemon's process view — everything under the user's HOME by default. The approver is the only containment layer. See Guides: Audit + approval policies for the pattern-mode + default: deny recipe.
write vs edit — when to use which
| Situation | Use |
|---|---|
| Creating a new file. | write. |
| Rewriting a file wholesale. | write. |
| Changing one section of a large file. | edit. It fails safely when old_string is not unique. |
| Renaming a symbol across the file. | Multiple edit calls with progressively more surrounding context, or a single write with the full rewritten contents. Do not use edit with replace_all-style semantics — the tool refuses. |
The exact-uniqueness constraint on edit is deliberate. It borrows directly from Claude Code's Edit tool. Search internal/tools/builtin/edit.go for the comment block that explains why.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
edit: path must be absolute, got "…" |
Model passed a relative path. | Reject or rewrite in the approver; ask the model to use absolute paths. |
edit: old_string not found in … |
The file changed since the model last read it, or the model hallucinated surrounding context. | The model will typically read again and retry. |
edit: old_string is not unique in … (found 3 occurrences) |
Same string appears multiple times. | Model must supply more surrounding lines to disambiguate. |
write: permission denied |
SELinux label mismatch or wrong UID mapping. | Verify :Z on the volume and UserNS=keep-id on the container. |
read: does not look like UTF-8 text |
File contains NUL bytes in first 512 bytes (isLikelyText in read.go). |
Refuse binary reads at the approver level; use the bash tool with file if identification is needed. |
Backups before big rewrites
The tools do not create .bak copies. For high-risk changes, teach the model to write to a sibling path first, bash diff it, then swap. Alternatively, run everything through a git branch — rousseau leaves git completely out of its execution path, so any versioning happens through your normal workflow.
Related
- Reference: Tool schemas — exact input schemas.
- User Guide: Tools.
- Deployment — the Quadlet unit that defines the bind mounts.
- Guides: Audit + approval policies — pinning writes to a directory tree.