Shell command execution tool.
Uses NetRunner for safe process execution with automatic timeout
handling and output size limits. Zero zombie processes guaranteed.
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.