Nous.Tools.PathGuard (nous v0.17.1)

Copy Markdown View Source

Path-traversal & symlink-escape protection for filesystem tools.

LLMs control the path argument to file tools. Without a guard, a single prompt-injected document can read ~/.aws/credentials, write to ~/.ssh/authorized_keys, or globsweep /etc/. This module enforces that every path resolves inside a configured workspace root.

Configuring the workspace root

Pass it via the agent's ctx.deps:

Agent.new("openai:gpt-4",
  tools: [Nous.Tools.FileRead, Nous.Tools.FileWrite],
  deps: %{workspace_root: "/srv/agent_workspace/#{user_id}"}
)

When workspace_root is unset, the guard defaults to the current working directory (File.cwd!/0). For multi-tenant deployments you almost certainly want to set it explicitly per session.

What's blocked

  • Paths that, after Path.expand/1, escape the configured root
  • Symlinks whose target escapes the root
  • Any path containing a NUL byte (defense-in-depth)

Returned path & TOCTOU

On success validate/2 returns the canonical, symlink-resolved path (every existing component dereferenced), not the raw argument. Callers MUST open that path so they operate on the same inode the guard validated, rather than re-traversing an attacker-swappable symlink in the original argument.

This narrows but does not fully eliminate a time-of-check/time-of-use race: between validate/2 returning and the caller opening the path, a writer with access to the workspace could still swap a now-resolved component for a symlink. Eliminating that window entirely requires openat/O_NOFOLLOW, which the Erlang :file API does not expose. The practical mitigation is to give each session a dedicated workspace_root that no other writer owns.

Summary

Functions

Best-effort realpath(3): resolve symlinks component by component for the portion of path that exists.

Resolve path against the configured workspace root and return either {:ok, canonical_path} or {:error, reason} where reason is a human-readable string suitable to surface back to the LLM.

Functions

resolve_real(path)

@spec resolve_real(String.t()) :: {:ok, String.t()} | {:error, :symlink_loop}

Best-effort realpath(3): resolve symlinks component by component for the portion of path that exists.

Non-existent trailing components cannot be symlinks, so they are appended verbatim — that is what lets Nous.Tools.FileWrite create a new file while still catching an escaping symlink anywhere above it.

Returns {:ok, canonical_path}, or {:error, :symlink_loop} once resolution exceeds 40 hops.

Path.expand/1 is not a substitute, and is not used here: it collapses .. lexically before resolving a preceding symlink, so <root>/link/.. where link -> /etc would wrongly resolve back inside the root instead of to /. That bug is the reason this function exists. .. and . are applied to the already-resolved prefix, component by component, exactly as the kernel does.

Shared with Nous.Sandbox.writable_roots/1, which needs the same canonicalisation to compare roots.

Examples

iex> {:ok, real} = Nous.Tools.PathGuard.resolve_real(System.tmp_dir!())
iex> Path.type(real)
:absolute

validate(path, ctx \\ nil)

@spec validate(String.t(), Nous.RunContext.t() | map() | nil) ::
  {:ok, String.t()} | {:error, String.t()}

Resolve path against the configured workspace root and return either {:ok, canonical_path} or {:error, reason} where reason is a human-readable string suitable to surface back to the LLM.

canonical_path is the symlink-resolved absolute path; callers should open it directly (see the "Returned path & TOCTOU" note in the moduledoc).