diff --git a/python/packages/tools/README.md b/python/packages/tools/README.md index 2cc7ed6ad22..8ea7b18e712 100644 --- a/python/packages/tools/README.md +++ b/python/packages/tools/README.md @@ -43,15 +43,20 @@ asyncio.run(main()) ### Safety > **`LocalShellTool` is not a sandbox.** It runs commands directly on the -> host with the agent process's privileges. The actual security boundary -> is **approval-in-the-loop**. For untrusted input use a sandboxed -> executor — see [`agent-framework-hyperlight`](#relationship-to-agent-framework-hyperlight). +> host with the agent process's permissions. Human approval provides a check +> before agent-requested commands run, but does not isolate the shell. +> Production use needs separately enforced isolation and restricted permissions. +> For untrusted input use a sandboxed executor, such as `DockerShellTool` for +> shell commands — see also +> [`agent-framework-hyperlight`](#relationship-to-agent-framework-hyperlight). Defenses (in priority order): -- **Approval-in-the-loop** — every command surfaces as a - `user_input_request`; nothing runs without consent. Disabling this - requires `acknowledge_unsafe=True`. +- **Approval-in-the-loop** — when an agent calls the tool returned by + `as_function()` with `approval_mode="always_require"` (the default), + commands require approval through a `user_input_request` before they run. + Disabling this requires `acknowledge_unsafe=True`. Direct calls to + `run()` do not request approval, regardless of the configured approval mode. - **Process-tree termination on timeout** via `psutil`, so child processes (`make`, watchers, network tools) cannot survive the timeout. - **Output truncation** to 64 KiB (head + tail with marker). @@ -61,22 +66,32 @@ Defenses (in priority order): security boundary — operators are expected to supply patterns that match their workload (and they can be defeated by trivial obfuscation such as `\rm -rf /`, `${RM:=rm} -rf /`, `python -c "…"`, encoded - payloads, or PowerShell-native equivalents). Real isolation comes from - approval gating and the sandbox tier (`DockerShellTool`). See + payloads, or PowerShell-native equivalents). Isolation must be enforced + separately, for example by the sandbox tier (`DockerShellTool`); + neither approval nor command filters isolate the shell. See `tests/test_security.py` for the documented residual risk surface. -Override with `ShellPolicy`: +Command-text filtering with `ShellPolicy` and required human approval: + +> [!WARNING] +> The filters below can allow embedded commands, including `$(...)` and +> backticks, and do not enforce read-only access. Review the full command before +> approving it. Approval does not isolate the shell: commands still run with +> the application's permissions. ```python from agent_framework.tools import LocalShellTool, ShellPolicy shell = LocalShellTool( policy=ShellPolicy(allowlist=[r"^ls\b", r"^cat\b", r"^git status$"]), - approval_mode="never_require", - acknowledge_unsafe=True, # required to bypass approval + approval_mode="always_require", ) ``` +Expose this tool to the agent through `shell.as_function()` and handle its +approval requests with human review. Calling `shell.run()` directly does not +request approval. + ### Cross-OS - **Windows**: `pwsh -NoProfile -Command -` (falls back to `powershell.exe`). @@ -160,7 +175,7 @@ container runtime with `docker_binary="podman"`. |---|---|---| | Run *code* (untrusted) | `HyperlightCodeActProvider.execute_code` (`agent-framework-hyperlight`) | Hyperlight WASM microVM | | Run *shell* (untrusted) | `DockerShellTool` | OCI container (network-off, non-root, capabilities dropped) | -| Run *shell* (trusted dev) | `LocalShellTool` | Approval-in-the-loop | +| Run *shell* (trusted dev) | `LocalShellTool` | None; human approval for agent calls by default | ## Relationship to `agent-framework-hyperlight` diff --git a/python/packages/tools/agent_framework_tools/shell/_tool.py b/python/packages/tools/agent_framework_tools/shell/_tool.py index 55acd1af7ce..86fb896739c 100644 --- a/python/packages/tools/agent_framework_tools/shell/_tool.py +++ b/python/packages/tools/agent_framework_tools/shell/_tool.py @@ -108,8 +108,9 @@ class LocalShellTool: ``cd``-wandering in one call does not leak to the next. This is a **re-anchor**, not a hard confinement — a command that does ``cd /tmp && rm -rf .`` in one call can still touch ``/tmp``. - Use :class:`ShellPolicy` or a sandboxed executor for true - confinement. + :class:`ShellPolicy` only filters command text; it does not + restrict file access. Use an executor with separately enforced + isolation and restricted permissions to limit access. env: Seed environment. In stateless mode this replaces the child's environment unless ``clean_env=False``. In persistent mode the variables are exported before the session is used. diff --git a/python/samples/02-agents/tools/README.md b/python/samples/02-agents/tools/README.md index c4bfaf4bc4e..a15f85f56c9 100644 --- a/python/samples/02-agents/tools/README.md +++ b/python/samples/02-agents/tools/README.md @@ -68,11 +68,19 @@ take effect on the **next iteration** of the function-calling loop. | Path | Demonstrates | |------|--------------| -| [`local_shell_with_allowlist.py`](local_shell_with_allowlist.py) | `LocalShellTool` restricted by a strict command allow-list. | +| [`local_shell_with_allowlist.py`](local_shell_with_allowlist.py) | Command-text filtering without human approval; not safe for production as shown. | | [`local_shell_with_environment_provider.py`](local_shell_with_environment_provider.py) | `LocalShellTool` wired with a `ShellEnvironmentProvider`. | | [`local_code_interpreter/`](local_code_interpreter/) | Hyperlight-backed sandboxed code interpreter (standalone tool — *extra* pattern). | | [`monty_code_interpreter/`](monty_code_interpreter/) | Monty-backed sandboxed code interpreter (standalone tool — *extra* pattern). | +> [!WARNING] +> The allow-list shell sample is educational, not a safe production configuration. +> Its filters can allow embedded shell commands and do not enforce read-only +> access. Commands run with the application's permissions, without human approval. +> Use only an isolated, disposable environment without secrets or valuable data. +> Production use needs separately enforced isolation and restricted permissions; +> human review can add a check, but does not isolate the shell. + > [!TIP] > The `local_code_interpreter/` and `monty_code_interpreter/` samples show the > standalone-tool wiring and are provided as *extra* reference. For most diff --git a/python/samples/02-agents/tools/local_shell_with_allowlist.py b/python/samples/02-agents/tools/local_shell_with_allowlist.py index 90c85a0a812..3da1503bd9e 100644 --- a/python/samples/02-agents/tools/local_shell_with_allowlist.py +++ b/python/samples/02-agents/tools/local_shell_with_allowlist.py @@ -8,12 +8,19 @@ from dotenv import load_dotenv """ -LocalShellTool with a strict allow-list (no approval loop). - -Every command must match one of the allow-list regexes and the deny-list -still wins. Approval is disabled because the allow-list is doing the -gating; this is the safest fully-automatic configuration of -``LocalShellTool``. +LocalShellTool with command-text filtering (no approval loop). + +WARNING: This is an educational example, not a safe production configuration. +Do not copy this approval-disabled setup into production. Use only an isolated, +disposable environment without secrets or valuable data. + +The allow-list checks command text, not what the shell will execute. Embedded +commands in $(...) or backticks can pass these filters and run; patterns that +only check the start of a command can also allow extra operations. These filters +do not enforce read-only access or protect against malicious model instructions. +Commands run with the application's permissions and can access or change files +and other resources available to it. Human approval and separately enforced +isolation are not replaced by an allow-list. """ load_dotenv() @@ -24,6 +31,7 @@ async def main() -> None: shell = LocalShellTool( mode="stateless", + # Unsafe for production as shown: these filters do not replace human approval or isolation. approval_mode="never_require", acknowledge_unsafe=True, policy=ShellPolicy( @@ -40,10 +48,7 @@ async def main() -> None: agent = Agent( client=client, - instructions=( - "You can run a narrow set of read-only shell commands (ls, pwd, cat, " - "git status/log/diff, python --version). Anything else will be rejected." - ), + instructions=("Use only these shell commands: ls, pwd, cat, git status/log/diff, python --version."), tools=[client.get_shell_tool(func=shell.as_function())], )