Nous.Tools.Bash (nous v0.17.2)

Copy Markdown View Source

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

__tool_schema__()

@spec __tool_schema__() :: map()

Return the full tool schema definition for introspection.

Includes parameter declarations, category, and tags.