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/3checks the leaf withFile.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".lineandlimitslice what was read.write/2creates 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 withO_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
lstatand 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
@spec read(String.t(), struct(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReadTextFileResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()}
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".
@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).
@spec write(String.t(), struct()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WriteTextFileResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()}
Handle fs/write_text_file (req is a ClientTypes.WriteTextFileRequest.t()) confined to sandbox_root. Creates parent directories as needed.