mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-24 11:55:52 +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.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.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.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. |
|
| `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,
|
timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
path_append=self.exec_config.path_append,
|
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(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
||||||
self.tools.register(WebFetchTool(proxy=self.web_proxy))
|
self.tools.register(WebFetchTool(proxy=self.web_proxy))
|
||||||
|
|||||||
@ -121,6 +121,7 @@ class SubagentManager:
|
|||||||
timeout=self.exec_config.timeout,
|
timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
path_append=self.exec_config.path_append,
|
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(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
||||||
tools.register(WebFetchTool(proxy=self.web_proxy))
|
tools.register(WebFetchTool(proxy=self.web_proxy))
|
||||||
|
|||||||
@ -23,9 +23,11 @@ class ExecTool(Tool):
|
|||||||
allow_patterns: list[str] | None = None,
|
allow_patterns: list[str] | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
path_append: str = "",
|
path_append: str = "",
|
||||||
|
command_wrapper: str = "",
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
|
self.command_wrapper = command_wrapper
|
||||||
self.deny_patterns = deny_patterns or [
|
self.deny_patterns = deny_patterns or [
|
||||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||||
@ -82,11 +84,16 @@ class ExecTool(Tool):
|
|||||||
self, command: str, working_dir: str | None = None,
|
self, command: str, working_dir: str | None = None,
|
||||||
timeout: int | None = None, **kwargs: Any,
|
timeout: int | None = None, **kwargs: Any,
|
||||||
) -> str:
|
) -> 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)
|
guard_error = self._guard_command(command, cwd)
|
||||||
if guard_error:
|
if guard_error:
|
||||||
return 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)
|
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|||||||
@ -136,6 +136,7 @@ class ExecToolConfig(Base):
|
|||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = 60
|
timeout: int = 60
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
|
command_wrapper: str = "" # sandbox wrapper command template; supports {command} and {cwd}
|
||||||
|
|
||||||
class MCPServerConfig(Base):
|
class MCPServerConfig(Base):
|
||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""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
|
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 ---
|
# --- _resolve_type and nullable param tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user