Shell command execution tool.
Uses NetRunner for safe process execution with automatic timeout
handling and output size limits. Zero zombie processes guaranteed.
:net_runner is an optional dependency: without it this tool refuses to
run any command (fail closed) — add {:net_runner, "~> 1.0"} to your deps.
Security
Commands run as the current OS user. Use Nous.Permissions to gate access to
this tool in production.
Environment
The spawned shell gets only Nous.Tools.Env.scrubbed/0, applied via
Nous.Tools.Env.with_scrubbed_env/1 — an /usr/bin/env -i prefix on the
argv — so it cannot printenv its way to OPENAI_API_KEY and friends.
The argv detour is not stylistic. NetRunner has no :env option: it
forwards unknown options to a port layer that ignores them and the shepherd
execvps, so the child inherits the BEAM's entire environment. This tool
passed env: Nous.Tools.Env.scrubbed() for its whole existence and that
option was silently discarded the entire time — printenv returned every key.
Do not "restore" it.
Sandbox
Every command is wrapped by Nous.Sandbox before it is spawned. The
effective policy comes from Nous.Sandbox.Policy.resolve/2, which reads (in
order) the run context's :sandbox field, ctx.deps[:workspace_root], and
the application environment.
The default is :danger_full_access — no confinement, with a one-time
warning logged per VM. That default stays until the next release so upgrading
cannot silently break a working deployment; it is not a recommendation. Opt
in with either:
# application-wide
config :nous, :sandbox_mode, :workspace_write
# or per agent / per run
Nous.new("openai:gpt-4", sandbox: :workspace_write)
Nous.run(agent, prompt, sandbox: :read_only)Under :read_only the OS refuses every write; under :workspace_write it
permits writes only under Nous.Sandbox.writable_roots/1 (the workspace
root, /tmp, and the system temp dir). When no provider can confine the
requested mode on this host, execute/2 refuses to spawn and returns
{:error, _} — running unconfined is never a silent fallback.
What confinement does not give you, on either provider: reads are
unrestricted, network egress is unrestricted, and only writes performed by
the confined process are fenced — a write it delegates to an already-running
system daemon over XPC (macOS defaults write, a container daemon) is
performed with that daemon's credentials and is out of scope. Nous.Sandbox
raises the cost of a prompt-injected rm -rf; it is not a boundary to hand an
adversary.
Sandbox denials come back as ordinary tool output with a
[sandbox: file access denied under <mode> mode] marker appended, so the
model can adapt. Treat the absence of that marker as "no denial observed",
not as proof a write landed — see Nous.Sandbox.classify/3. A failure of the
sandbox runner itself is reported as an error saying the command never ran,
because "bwrap could not create a namespace" must never read as "confinement
worked".
stderr
stderr is merged into the returned output via
Nous.Sandbox.merge_stderr/1, which wraps the confined argv in
/bin/sh -c 'exec "$@" 2>&1'.
Do not "simplify" this into NetRunner's stderr: :redirect option. That
mode is documented but unimplemented in net_runner 1.0 — nothing branches on
it — and passing it is worse than the default, because it also switches off
the :consume drain and leaves a live stderr pipe with no reader, so a child
writing more than a pipe buffer to stderr blocks forever. This tool therefore
passes no :stderr option at all and keeps the default.
Merging matters twice over: without it the model never sees a compiler error
or a stack trace, and Nous.Sandbox.classify/3 has nothing to classify.
Because exec replaces the wrapper shell, fd 2 is redirected for the sandbox
runner itself, so sandbox-exec: / bwrap: runner failures are captured
too — a 2>&1 inside the inner command would miss exactly those.
Spilling large results
A megabyte of grep output costs roughly 250k tokens of context and is almost
never read in full. With Nous.Spill configured, the agent runner replaces an
oversized tool result with a preview plus a locator the model can fetch on
demand, and this tool additionally stores the prefix of a command that blows
the 1 MB output ceiling — output that was previously truncated and discarded.
Opt in per run, in deps:
Nous.run(agent, prompt,
deps: %{
spill_config: %{
store: Nous.Spill.Local,
opts: [root: "/var/lib/nous/spill"],
max_inline_bytes: 65_536
}
}
)Or application-wide, for a deployment that wants it everywhere:
config :nous, :spill, %{store: Nous.Spill.Local, opts: [root: "/var/lib/nous/spill"]}With neither, output is returned inline and a truncated command keeps its
plain [Output truncated at 1000000 bytes] marker, exactly as before —
spilling is opt-in and changes nothing until it is configured.
What spilling can not do is recover the whole output of a chatty command:
NetRunner kills the process at the cap, so the 1 MB prefix is all that was
ever captured, and the notice says so rather than promising a full transcript.
Spilled content persists until you delete it; see Nous.Spill.
Summary
Functions
Return the full tool schema definition for introspection.
Functions
@spec __tool_schema__() :: map()
Return the full tool schema definition for introspection.
Includes parameter declarations, category, and tags.