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):
- Ref-shape gate — when
ref_format:is given, the RAWrequestedstring 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. - Lexical confinement —
root = Path.expand(root);lexical = Path.expand(Path.join(root, requested)). Unlesslexicalequalsrootor starts withroot <> "/",{:error, :path_traversal}. (An absoluterequestedis jailed underrootbyPath.join, not honored.) - Symlink resolution — a hand-rolled realpath of BOTH
rootandlexical: 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}. - 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
Functions
Confine requested under root.
Types
@type reason() ::
:malformed_ref
| :path_traversal
| :too_many_symlinks
| :symlink_escape
| :invalid_input
Rejection reason returned by confine/3.
Functions
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— aRegex.t()the RAWrequestedstring must match before any resolution (e.g. PA-6's~r/^(blobs|snapshots)\/[0-9a-f]{64}(\.json)?$/). Absent = no gate.