# `Nous.Tools.Bash`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/tools/bash.ex#L1)

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
`execvp`s, 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`.

# `__tool_schema__`

```elixir
@spec __tool_schema__() :: map()
```

Return the full tool schema definition for introspection.

Includes parameter declarations, category, and tags.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
