ConduitMcp.Client.Policy.Paths (ConduitMCP v0.11.0)

Copy Markdown View Source

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 :resolve option, 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/3 is 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/3 is 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

reason()

@type reason() ::
  :not_a_string
  | :nul_byte
  | :invalid_unicode
  | :relative_root
  | :resolve_failed

Why a path did not canonicalize.

selector()

@type selector() :: String.t() | [String.t() | non_neg_integer()]

Selects one argument: a key, or a list of keys and list indexes.

Functions

canonicalize(path, root, opts \\ [])

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

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}

escapes(selector, root, opts \\ [])

@spec escapes(selector(), String.t(), keyword()) :: (map() -> boolean())

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.

inside(selector, root, opts \\ [])

@spec inside(selector(), String.t(), keyword()) :: (map() -> boolean())

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.

inside?(path, root, opts \\ [])

@spec inside?(term(), String.t(), keyword()) :: boolean()

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