Nous.Tools.PathGuard (nous v0.16.4)

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

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

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).