Raxol.AgentClientProtocol.Client.FsSandbox (Raxol Agent Client Protocol v0.1.0)

Copy Markdown View Source

Confines fs/read_text_file / fs/write_text_file handling to a sandbox directory -- backs use Raxol.AgentClientProtocol.Client, fs_sandbox: path (see that module's moduledoc for the option). Not a general-purpose path library: it exists solely to give a minimal client a working filesystem story out of the box.

Path checks

Every request path is joined under the sandbox root and lexically normalized (.. resolved via Path.expand/1), checked against the root, THEN fully symlink-resolved (resolve/2, a hand-rolled realpath) and checked against the root a second time -- so a ../ escape and a symlink planted inside the sandbox that points outside it are both caught, not just the lexical case. Every refusal is a -32602 invalid params Error.t(), safe to send straight to the peer: data carries a "reason" and the path the peer sent, never the resolved absolute path.

Opening the file

A path that is checked and then opened leaves a window in which a peer that can change the tree inside the root swaps the file for a symlink. So the file is opened first and the open handle verified afterwards: the device and inode of the handle (:file.read_file_info/1 on it) must equal those of the path re-resolved with resolve/2 and read with File.lstat/1, which does not follow a symlink at the leaf, and both must be a regular file. A mismatch is refused as "path_changed"; a leaf that is not a regular file (a directory, FIFO, device, or a symlink swapped in for one) as "not_a_regular_file".

  • read/3 checks the leaf with File.lstat/1, opens it read-only, verifies the handle, and reads at most :max_read_bytes (default 16 MiB); a larger file is refused as "file_too_large". line and limit slice what was read.
  • write/2 creates missing parent directories one at a time, each checked to be a directory and not a symlink, and re-resolves the path afterwards. A new file is created with O_EXCL, which never follows a symlink at the leaf; an existing file is opened without truncating it. The handle is verified before it is truncated and written.

What is and is not guaranteed

Erlang's file API has no openat or O_NOFOLLOW, so checking a path and opening it are separate system calls. With the verification above:

  • Swapping the requested file itself (for a symlink, a FIFO, a device or another file) while a request runs cannot make the request read or write a file outside the root.
  • A peer that can also rename the DIRECTORIES a requested path goes through, and wins two races within one request (at the open and at the verifying lstat), can still redirect that request. A path whose only directory is the root itself is not exposed to this.
  • There is no open mode that writes an existing file without O_CREAT, so a leaf swapped for a dangling symlink at that instant, or a parent directory swapped as above, can create an EMPTY file or directory outside the root. Nothing is written into it: the handle check refuses first.
  • A leaf swapped for a FIFO between the lstat and the read-only open blocks that one request until the FIFO has a writer.
  • A hard link inside the root to a file outside it is that file; hard links are not detected.
  • On non-Unix file systems the inode is reported as zero, so the handle check compares only the device and the file type.

fs_sandbox confines the paths the peer names. It is not a boundary against a peer that can also change the sandbox tree by other means while requests run: the checks above narrow those races but do not close them.

Summary

Functions

Handle fs/read_text_file (req is a ClientTypes.ReadTextFileRequest.t()) confined to sandbox_root.

Resolve requested_path against sandbox_root, rejecting any result that escapes the sandbox -- lexically (../ traversal) or via a symlink (on the requested file itself or on any ancestor directory in its path). Returns {:ok, real_absolute_path} or {:error, %Error{}} (-32602 invalid params).

Handle fs/write_text_file (req is a ClientTypes.WriteTextFileRequest.t()) confined to sandbox_root. Creates parent directories as needed.

Functions

read(sandbox_root, req, opts \\ [])

Handle fs/read_text_file (req is a ClientTypes.ReadTextFileRequest.t()) confined to sandbox_root.

Options: :max_read_bytes (default 16 MiB), the largest file read; a larger one is refused as "file_too_large".

resolve(sandbox_root, requested_path)

@spec resolve(String.t(), String.t()) ::
  {:ok, String.t()} | {:error, Raxol.AgentClientProtocol.Error.t()}

Resolve requested_path against sandbox_root, rejecting any result that escapes the sandbox -- lexically (../ traversal) or via a symlink (on the requested file itself or on any ancestor directory in its path). Returns {:ok, real_absolute_path} or {:error, %Error{}} (-32602 invalid params).

write(sandbox_root, req)

Handle fs/write_text_file (req is a ClientTypes.WriteTextFileRequest.t()) confined to sandbox_root. Creates parent directories as needed.