Nous.Tools.Bash (nous v0.17.1)

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.

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.