feat(exec): add command_wrapper for sandbox support

Allow users to wrap shell commands in a sandbox (e.g. bubblewrap,
firejail, nsjail) via config, without nanobot baking in any sandbox
backend. The template supports {command} and {cwd} placeholders with
plain string replacement.
This commit is contained in:
chengyongru 2026-03-31 23:47:23 +08:00
parent a4dfbdf996
commit 5257453c4c
7 changed files with 149 additions and 1 deletions

View File

@ -1387,6 +1387,8 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `tools.exec.commandWrapper` | `""` | Sandbox wrapper command template. See [Exec Tool Sandbox](docs/COMMAND_WRAPPER.md) for details and examples. |
| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |

86
docs/COMMAND_WRAPPER.md Normal file
View File

@ -0,0 +1,86 @@
# Exec Tool Sandbox (`commandWrapper`)
The `tools.exec.commandWrapper` config option wraps every shell command in a user-defined template before execution. This allows you to add a sandbox layer (e.g. bubblewrap, firejail, nsjail) without any code changes to nanobot.
## Configuration
```json
{
"tools": {
"exec": {
"commandWrapper": "<template>"
}
}
}
```
Leave empty (the default) to run commands directly with no wrapper.
## Placeholders
Two placeholders are available in the template:
| Placeholder | Value |
|---|---|
| `{command}` | The original shell command generated by the LLM |
| `{cwd}` | Absolute path of the working directory |
nanobot performs plain string replacement — it does not parse, validate, or shell-escape the values. The wrapper template is trusted configuration.
## Examples
### bubblewrap
```json
{
"tools": {
"exec": {
"commandWrapper": "bwrap --ro-bind /usr /usr --ro-bind-try /bin /bin --ro-bind-try /lib /lib --ro-bind-try /lib64 /lib64 --proc /proc --dev /dev --tmpfs /tmp --bind {cwd} {cwd} --chdir {cwd} -- sh -c \"{command}\""
}
}
}
```
Requires: `apt install bubblewrap` (or equivalent for your distro).
### firejail
```json
{
"tools": {
"exec": {
"commandWrapper": "firejail --noprofile --private={cwd} -- {command}"
}
}
}
```
### nsjail
```json
{
"tools": {
"exec": {
"commandWrapper": "nsjail -Mo --chroot /sandbox --cwd {cwd} -- {command}"
}
}
}
```
## Caveats
> [!WARNING]
> **Do not wrap `{command}` in shell quotes.** If the original command contains the same quote character, the shell will break the quoting context. For example, `sh -c '{command}'` will fail on any command that contains single quotes.
This is an inherent limitation of the template approach — nanobot substitutes `{command}` as a raw string and cannot safely shell-quote it (the command may contain compound syntax like `&&`, `|`, `;` that must be preserved for the inner shell).
### Interaction with `create_subprocess_shell`
nanobot executes the wrapped command via `create_subprocess_shell`, which adds an outer shell layer. Keep this in mind when designing your template:
- **Without `sh -c`** (e.g. `firejail ... -- {command}`): The outer shell parses `{command}` directly. Compound commands with `&&` and `|` work as expected because they are parsed by the outer shell before the sandbox tool receives them.
- **With `sh -c`** (e.g. `bwrap ... -- sh -c "{command}"`): The command is passed through two shell layers. This is only needed if the sandbox tool requires a single command argument but you want to support compound syntax.
### `restrict_to_workspace` is independent
The `tools.restrictToWorkspace` setting and `commandWrapper` are orthogonal features. The workspace restriction guards against path traversal in the original command (before wrapping). The sandbox wrapper provides OS-level isolation. You can use either or both — they address different threat models.

View File

@ -248,6 +248,7 @@ class AgentLoop:
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
command_wrapper=self.exec_config.command_wrapper,
))
self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
self.tools.register(WebFetchTool(proxy=self.web_proxy))

View File

@ -121,6 +121,7 @@ class SubagentManager:
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
command_wrapper=self.exec_config.command_wrapper,
))
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
tools.register(WebFetchTool(proxy=self.web_proxy))

View File

@ -23,9 +23,11 @@ class ExecTool(Tool):
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
path_append: str = "",
command_wrapper: str = "",
):
self.timeout = timeout
self.working_dir = working_dir
self.command_wrapper = command_wrapper
self.deny_patterns = deny_patterns or [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
@ -82,11 +84,16 @@ class ExecTool(Tool):
self, command: str, working_dir: str | None = None,
timeout: int | None = None, **kwargs: Any,
) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
cwd = os.path.abspath(working_dir or self.working_dir or os.getcwd())
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
if self.command_wrapper:
original_command = command
command = self.command_wrapper.replace("{cwd}", cwd).replace("{command}", command)
logger.debug("command_wrapper applied: {} -> {}", original_command, command)
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
env = os.environ.copy()

View File

@ -136,6 +136,7 @@ class ExecToolConfig(Base):
enable: bool = True
timeout: int = 60
path_append: str = ""
command_wrapper: str = "" # sandbox wrapper command template; supports {command} and {cwd}
class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""

View File

@ -408,6 +408,56 @@ async def test_exec_timeout_capped_at_max() -> None:
assert "Exit code: 0" in result
async def test_exec_command_wrapper_applied() -> None:
"""command_wrapper should wrap the original command."""
tool = ExecTool(command_wrapper="echo WRAPPED: {command}")
result = await tool.execute(command="hello")
assert "WRAPPED: hello" in result
assert "Exit code: 0" in result
async def test_exec_command_wrapper_with_cwd(tmp_path) -> None:
"""command_wrapper should substitute {cwd} with the absolute working directory."""
tool = ExecTool(command_wrapper="echo CWD:{cwd} CMD:{command}")
result = await tool.execute(command="hi", working_dir=str(tmp_path))
assert str(tmp_path) in result
assert "CMD:hi" in result
async def test_exec_command_wrapper_empty_noop() -> None:
"""Empty command_wrapper should leave the command unchanged."""
tool = ExecTool(command_wrapper="")
result = await tool.execute(command="echo direct")
assert "direct" in result
assert "Exit code: 0" in result
async def test_exec_command_wrapper_guard_runs_before_wrapper() -> None:
"""Safety guard should run before wrapper substitution."""
tool = ExecTool(command_wrapper="echo WRAPPED:{command}")
result = await tool.execute(command="rm -rf /")
assert "blocked by safety guard" in result
assert "WRAPPED:" not in result
async def test_exec_command_wrapper_ignores_unknown_placeholders() -> None:
"""Unknown {placeholders} in the wrapper should be left as-is, not raise KeyError."""
tool = ExecTool(command_wrapper="echo {command} {unknown}")
result = await tool.execute(command="hello")
assert "Exit code: 0" in result
assert "{unknown}" in result
async def test_exec_command_wrapper_does_not_leak_attributes() -> None:
"""Wrapper should not expose Python internals via attribute access."""
tool = ExecTool(command_wrapper="echo {command.__class__}")
result = await tool.execute(command="hello")
assert "Exit code: 0" in result
# {command.__class__} is not a valid placeholder; {command} gets replaced
# leaving {.__class__} as a literal string — no Python object is leaked.
assert "<class" not in result
# --- _resolve_type and nullable param tests ---