# `Nous.Tools.PathGuard`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/tools/path_guard.ex#L1)

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.

# `resolve_real`

```elixir
@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`

```elixir
@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).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
