Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions python/packages/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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`).
Expand Down Expand Up @@ -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`

Expand Down
5 changes: 3 additions & 2 deletions python/packages/tools/agent_framework_tools/shell/_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion python/samples/02-agents/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions python/samples/02-agents/tools/local_shell_with_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
westey-m marked this conversation as resolved.
Resolved

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()
Expand All @@ -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(
Expand All @@ -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())],
)

Expand Down
Loading