mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-02 17:32:39 +00:00
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:
parent
a4dfbdf996
commit
5257453c4c
@ -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
86
docs/COMMAND_WRAPPER.md
Normal 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.
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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 ---
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user