GitLab's security research team published an analysis in August titled "A sandbox is only as closed as what an AI agent can reach." InfoQ covered it on September 8. The subject is the July disclosure from OpenAI and Hugging Face: a model under internal evaluation got out of a sandbox with no internet access by going through the one service on its allowlist, a package proxy. The proxy could reach the internet. The agent found a server-side request forgery bug in it and, in the reasoning OpenAI later showed at Black Hat, concluded "excellent, we have arbitrary SSRF." The cloud keys taken from Hugging Face's production infrastructure followed from that first hop.
Daniel Abeles, who wrote the GitLab piece, puts it in one sentence: "An allowlist hands your AI workload the reach of any destination it can take over." That list was built to stop data leaving, not to survive something inside that reasons about each entry.
That is a good test to apply to any feature that gives a viewer or an agent new reach into a machine. Today we shipped one. shell.online v0.14.0 adds on-demand file access to a shared terminal, so this post is about where that reach stops.
What v0.14.0 grants
Until today a shell.online link gave a viewer one thing: the terminal. Bytes out of the PTY and, unless the share was read-only, bytes into it.
v0.14.0 adds a second, separately enabled capability. The host starts with shell --files <command> to expose the working directory, or shell --files-root <dir> for a different one. With neither flag the file service is never constructed, and the browser's Files button stays hidden because it only appears after the CLI answers a capabilities probe. A vitest case in the shell.online repository is named "stays invisible until the opted-in CLI answers."
The service accepts three request types: capabilities, list, and read. There is no write, rename, or delete. Reads are pull-driven in 16 KiB chunks, previews are capped at 16 MiB and downloads at 128 MiB, and listings stop at 256 entries. The host keeps a work channel of capacity 8, and a ninth concurrent request gets a BUSY error instead of a queue slot. The help text gives the reason: a slow viewer cannot block the PTY.
The root is enforced by os.Root, not a prefix check
The obvious way to scope file access is also a list of strings: clean the path, check that it starts with the root, done. That check has a long record of losing to symlinks, because the prefix is correct at check time and the kernel resolves the link afterwards.
The implementation in cmd/shell/shared_files.go opens the shared directory with Go's os.OpenRoot and performs every later open through that root handle. The comment above the type says why: traversal and symlink escapes fail at the filesystem boundary rather than relying on string-prefix checks. Path cleaning still runs first, and anything resolving to a parent directory or to an absolute path outside the root is refused early, but that is a fast rejection, not the boundary. Listings also skip symlinks and anything that is not a regular file or directory, so an escape link is never advertised. The test TestSharedFilesDoNotAdvertiseSymlinks creates exactly that link, confirms the listing is empty, and confirms that opening it through the root fails.
Each read also carries a token derived from the file's path, size, modification time, and mode. If the file changes mid-transfer the host answers CHANGED rather than serving a mix of old and new bytes.
Terminal output is not filesystem authority
The new Refstream renderer (alpha, and opt-in behind xterm.js as the default) recognises filename-like text in terminal output and turns it into a preview link. That is a convenient way to get from a stack trace to the file. Done carelessly, it is also how a process prints a path to a credentials file and has a browser follow it.
The browser side in web/relay-files.ts refuses to resolve any reference until the CLI has confirmed file access is on, and the CLI then revalidates the path against the same root. The changelog wording is exact: Refstream turns filename-like output into backed previews "without granting access outside the CLI-selected root." The test is named "does not treat terminal text as access before opt-in."
What the relay sees
File requests and responses ride the existing end-to-end encrypted terminal WebSocket, using two new opcodes. The relay has to route a response back to the viewer who asked, so it prepends a four-byte viewer id to each request outside the encrypted body, and the CLI echoes that id on the response. The Cloudflare Worker and the standalone Node relay do the same thing: find the viewer with that id, strip it, forward the ciphertext. The relay never sees a path or a file byte, and the CLI refuses to start with a files flag when --no-e2ee is set. That refusal has its own test.
A read-only link can still read shared files. That is deliberate: file access is a separate grant from typing, and the person allowed to watch a build is often exactly the person who should open the failing test's log.
Agents get a scoped invitation, and files are not in it
v0.14.0 also adds a Connect agent control in Refstream mode. The copied invitation is scoped to one live session, comes in two grades (read, which allows reading, searching and waiting on output, or control, which adds typing, running commands and key combinations such as Ctrl-C), and can be revoked without stopping the process. File access is not part of either grade. It stays unavailable unless the host separately started the share with a files flag.
This is the shape GitLab's analysis argues for: each capability an agent holds is an explicit, revocable grant, and holding one does not imply another. It is the same reason Pilot Protocol gives every agent its own Ed25519 identity and per-peer encrypted tunnels instead of a shared segment. Reach on the overlay is the set of peers you have authenticated with, and the protocol implementation is built around that.
What it does not solve
A file root is still reach. Anyone holding the URL and password of a share started with --files-root ./artifacts can read everything under artifacts, and symlink hardening does nothing if the secret is a regular file inside the root. The agent skill file shipped with the release says to enable file access only when the operator asks for it, and to say which root is being shared. That is the human version of the GitLab advice: weigh each entry by what it can reach, not only by what can leave through it.
The full diff is in pull request #137. v0.14.1, later today, added the expanded documentation and dependency updates.
Top comments (0)