Field Reports

When the launcher reads your command first

Two recent changes to Cat-AgentUI's sandbox and Workspace: a bug where systemd-run silently ate `${...}` in sandboxed commands, and the checks I added when the file panel learned to move whole folders.

Most of the security work on Cat-AgentUI is not dramatic. It is noticing that a layer I trust is doing something I did not ask for, and then making the boundary say exactly what I mean. Two changes from the past couple of weeks are good examples, so I am writing them down while they are fresh.

A command that arrived empty

Sandboxed commands in Cat-AgentUI run under bubblewrap, and when the host's systemd user instance is available, each one is launched through systemd-run --user so it gets a cgroup with memory, CPU, process-count, and runtime limits.

The symptom was small. A command like for f in *; do echo "${f##*/}"; done printed blank lines. The same lines written into a script file and then executed worked fine.

The cause was that the service manager reads the command line before the shell ever sees it. systemd expands ${VAR} in the arguments the same way it does in ExecStart=, and an unknown variable becomes an empty string. So ${n} and ${f##*/} reached sh already gone. Commands in a script file were unaffected because systemd never looked inside the file.

The fix, in commit 0f1c102, escapes every $ in the arguments passed to systemd-run as $$, which is systemd's spelling of a literal dollar sign. There is a cleaner flag, --expand-environment=no, but it needs systemd 254 or newer, and I did not want correctness to depend on the host's systemd version.

I count this as a security-adjacent fix rather than just a bug, for two reasons. First, a launcher that rewrites what the user wrote is a boundary that is not behaving as documented, and that is exactly the kind of gap that turns into a surprise later. Second, the same change stopped carrying values such as HOME and USER from the host's systemd user instance into sandboxed commands. The sandbox should know as little about the host as possible, and that was a small leak of host context I had not intended.

Letting the file panel move folders

The second change, commit b8d1c08, is a feature: the Workspace panel can now create folders, rename, and move, delete, or zip-download several items at once. Any time a file panel learns to move things, the questions are the same. Can a path escape the workspace? Can one bad item in a batch leave things half-done? Can a move create a state the rest of the code cannot handle?

Here is what the new endpoints do:

  • Same path handling as before. The new mkdir, move, delete, and zip operations reuse the existing approach: paths are opened one component at a time from a directory descriptor with O_NOFOLLOW, and host writes share the per-workspace lock with sandbox runs. Paths like ../out are rejected, and so are hidden names such as .secret.
  • No silent overwrites. If the destination already has a file with the same name, that item fails with a conflict and both files are left alone.
  • Per-item results. A batch reports which items moved and which failed, instead of stopping halfway with an unclear state.
  • No folder inside itself. Moving docs into docs/sub is refused.
  • Depth stays bounded. Before moving a folder, the code measures how deep its contents go. If the move would push anything past the 8-level limit, it is refused, because those files would no longer be reachable through the normal path resolver.
  • Hidden folders stay out of the listing. Previously, files inside hidden directories were listed but could not be opened. Now they are left out of the listing, while still counting toward storage.

The commit also adds a test:workspace-organize regression script that runs these cases through the real routes, a real temporary filesystem, and a temporary SQLite database, including the traversal, conflict, self-nesting, and depth cases.

What I took from it

Both changes come down to the same habit: check what each layer between the user and the effect actually does, and do not assume it passes things through untouched. For systemd that meant reading how it treats a command line. For the file panel it meant writing down the bad states a move could create and testing each one.

If you run Cat-AgentUI yourself and find a way around any of this, please report it privately as described in SECURITY.md.