Nous.Spill.Local (nous v0.17.1)

Copy Markdown View Source

Filesystem backend for Nous.Spill.

Layout

<root>/session-<sha256_hex(owner)>/<random>-<safe_name>

root comes from opts[:root] and defaults to Path.join(System.tmp_dir!(), "nous-spill").

The owner is hashed, never used raw: a session id can contain a path separator, a .., or be derived from something a user controls, and any of those would let the owner steer the write out of root. A hex digest cannot. The <random> component means two saves with the same owner and name never collide, so a spilled result is never silently overwritten by a later one.

Permissions

Spilled content is whatever a tool just read or produced — file contents, command output, search hits. On a shared host that is nobody else's business, so the session directory is 0700 and each file is 0600.

Both need an explicit File.chmod/2. File.mkdir_p/1 takes no mode argument, so a fresh directory is created at 0777 &&& ~umask, which on a typical 022 umask leaves it group- and world-readable. File.open/2 is the same story for the file. The file is chmodded before its bytes are written, so the content never exists on disk at the laxer inherited mode.

Exclusive create

Files are opened [:write, :exclusive, :binary]. Exclusive create is what stops a planted symlink from redirecting the write: O_EXCL fails outright if the path already exists — including as a symlink — instead of following it and truncating whatever it points at. A pre-created <random>-<safe_name> symlink is therefore an error, not an arbitrary-file overwrite.

Retention

Files persist until the operator deletes them. See the retention section of Nous.Spill for why there is deliberately no reaper here.

Summary

Functions

Read a spilled file back, byte for byte.

Tell the model how to open this locator: the concrete path, and the tool.

Write content under <root>/session-<sha256_hex(owner)>/<random>-<safe_name>.

Functions

fetch(locator)

@spec fetch(Nous.Spill.Locator.t()) :: {:ok, binary()} | {:error, term()}

Read a spilled file back, byte for byte.

Returns File.read/1's error tuples unchanged — {:error, :enoent} for a file the operator has since deleted, {:error, :eacces} for one it can no longer read.

retrieval_hint(locator)

@spec retrieval_hint(Nous.Spill.Locator.t()) :: String.t()

Tell the model how to open this locator: the concrete path, and the tool.

The hint names file_read, which is fenced to the workspace root by Nous.Tools.PathGuard. A spill :root outside that workspace therefore produces a path the model cannot read, and the hint does not pretend otherwise — it states where the content is and which tool opens it, and leaves reachability to the operator who chose the root. Keeping the spill root inside the workspace is what makes the hint actionable.

save_text(attrs)

@spec save_text(Nous.Spill.attrs()) ::
  {:ok, Nous.Spill.Locator.t()} | {:error, term()}

Write content under <root>/session-<sha256_hex(owner)>/<random>-<safe_name>.

attrs is the Nous.Spill.attrs/0 map plus the :opts keyword list Nous.Spill.save_text/2 injects from configuration. Only opts[:root] is read.

Returns {:error, term} for every failure — a missing root, an unwritable directory, a full disk — and never leaves a partial file behind: if the write or the chmod fails after the file was created, the file is removed.