Raxol.Core.Boundary.Path (Raxol Core v2.7.0)

Copy Markdown View Source

Path-traversal confinement: resolve an untrusted requested reference under a trusted root and prove the result stays inside that root, rejecting every escape before any syscall touches the target.

This is the centralized seed of the "same gap, four patches" boundary (PR #569 thread 2). It is one of two boundary confinements — the other is Raxol.Core.Boundary.TermText (terminal-injection). They share a threat narrative but nothing else, so they are two functions, not one.

Seeded from the proven Raxol.AgentClientProtocol.Client.FsSandbox.resolve/2 (leaf-symlink and symlinked-ancestor resolution, cycle-guarded, rejecting before any target syscall) plus PA-6's ref-shape gate. The Agent Client Protocol package keeps its own tested copy as a documented intentional duplicate; both bind to the same shared conformance vectors (test/support/boundary_vectors/) so drift is a red test, not a silent fork.

Rule order

confine/3 enforces these in order. Everything below the ref gate happens before any syscall on the target (the only I/O is :file.read_link during symlink resolution, which reads links, never opens/creates the target):

  1. Ref-shape gate — when ref_format: is given, the RAW requested string must match the regex, else {:error, :malformed_ref}. Refs are validated as opaque tokens before they are ever treated as paths. Skipped when the option is absent.
  2. Lexical confinement — root = Path.expand(root); lexical = Path.expand(Path.join(root, requested)). Unless lexical equals root or starts with root <> "/", {:error, :path_traversal}. (An absolute requested is jailed under root by Path.join, not honored.)
  3. Symlink resolution — a hand-rolled realpath of BOTH root and lexical: follows a leaf symlink, recurses into the parent so a symlinked ancestor directory is caught too, resolves POSIX relative link targets against the link's own directory, and works for a not-yet-existing write leaf by resolving the deepest existing ancestor. Depth-capped at 40; a cycle yields {:error, :too_many_symlinks}.
  4. Post-resolution re-check — the real path must still be within the real root, else {:error, :symlink_escape}.

Total function: never raises on bad input (a non-binary root/requested yields {:error, :invalid_input}).

Summary

Types

Rejection reason returned by confine/3.

Functions

Confine requested under root.

Types

reason()

@type reason() ::
  :malformed_ref
  | :path_traversal
  | :too_many_symlinks
  | :symlink_escape
  | :invalid_input

Rejection reason returned by confine/3.

Functions

confine(root, requested, opts \\ [])

@spec confine(String.t(), String.t(), keyword()) ::
  {:ok, String.t()} | {:error, reason()}

Confine requested under root.

Returns {:ok, real_absolute_path} when the fully symlink-resolved target provably stays inside the resolved root, or {:error, reason} (see the moduledoc rule order for the exact reason per rejection).

Options

  • :ref_format — a Regex.t() the RAW requested string must match before any resolution (e.g. PA-6's ~r/^(blobs|snapshots)\/[0-9a-f]{64}(\.json)?$/). Absent = no gate.