Nous.Tools.PathGuard (nous v0.16.4)
View SourcePath-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
@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).