Path canonicalization and path predicates for ConduitMcp.Client.Policy
argument rules (PO-9).
root = "/srv/sandbox"
ConduitMcp.Client.Policy.compile(
deny: [{:args, ConduitMcp.Client.Policy.Paths.escapes("path", root)}],
allow: [{:all, ["fs/write", {:args, ConduitMcp.Client.Policy.Paths.inside("path", root)}]}]
)Argument rules are defence in depth, not a sandbox: the server is the enforcement point. These helpers only make the client's view of a path hard to fool with encodings.
Canonicalization
canonicalize/3 turns an argument into an absolute, normalized path, or
fails:
- the value must be a valid UTF-8 string with no NUL byte; it is normalized to Unicode NFC;
- a relative path is joined to
root;~is not expanded (it is an ordinary file name); .segments and empty segments are dropped and..removes the previous segment, lexically (..at/stays at/);- with the
:resolveoption, the lexical result is passed to that function (for example one that resolves symlinks with:file.read_link_all/1) and its{:ok, path}result is normalized again; anything else is a failure. Nothing here touches the file system by itself.
The root goes through the same steps (it must be absolute), so
inside?/3 compares like with like.
Predicates
inside/3 and escapes/3 build {:args, fun} predicates that read one
argument, selected by a key ("path") or a key/index list
(["files", 0, "path"], string keys only). A list value is checked element
by element. They differ in how they fail:
inside/3is for allow rules: it matches only when every path canonicalizes and lies within the root. A missing argument, an empty list, or any failure is no match.escapes/3is for deny and ask rules: it matches when any path fails to canonicalize (including a non-string value) or lies outside the root. A missing argument is no match (there is no path to check).
Summary
Types
Why a path did not canonicalize.
Selects one argument: a key, or a list of keys and list indexes.
Functions
Canonicalizes path against the absolute root; see the moduledoc.
An argument predicate (for {:args, fun}) that matches when the selected
argument, or any element of it when it is a list, fails to canonicalize or
lies outside root.
An argument predicate (for {:args, fun}) that matches when the selected
argument is a path, or a non-empty list of paths, all inside root.
Whether path canonicalizes and lies within root (or is root itself).
Takes the same options as canonicalize/3; any failure is false.
Types
@type reason() ::
:not_a_string
| :nul_byte
| :invalid_unicode
| :relative_root
| :resolve_failed
Why a path did not canonicalize.
@type selector() :: String.t() | [String.t() | non_neg_integer()]
Selects one argument: a key, or a list of keys and list indexes.
Functions
Canonicalizes path against the absolute root; see the moduledoc.
Options
:resolve-(String.t() -> {:ok, String.t()} | {:error, term()}), applied to the lexically normalized path (default: none).
Examples
iex> ConduitMcp.Client.Policy.Paths.canonicalize("../../etc/passwd", "/srv/sandbox")
{:ok, "/etc/passwd"}
iex> ConduitMcp.Client.Policy.Paths.canonicalize("a/./b//c/..", "/srv")
{:ok, "/srv/a/b"}
iex> ConduitMcp.Client.Policy.Paths.canonicalize("a\0b", "/srv")
{:error, :nul_byte}
An argument predicate (for {:args, fun}) that matches when the selected
argument, or any element of it when it is a list, fails to canonicalize or
lies outside root.
Raises ArgumentError at build time when root is not an absolute path
or selector is malformed.
An argument predicate (for {:args, fun}) that matches when the selected
argument is a path, or a non-empty list of paths, all inside root.
Raises ArgumentError at build time when root is not an absolute path
or selector is malformed.
Whether path canonicalizes and lies within root (or is root itself).
Takes the same options as canonicalize/3; any failure is false.
iex> ConduitMcp.Client.Policy.Paths.inside?("/data/../etc", "/data")
false
iex> ConduitMcp.Client.Policy.Paths.inside?("/data/x", "/data")
true