Muokkaa

Shell tools

The beta agent-framework-tools Python package provides shell execution and environment-awareness tools through the agent_framework.tools namespace.

Tool Use it when
LocalShellTool Commands are trusted or individually approved and should run in the agent process's host environment.
DockerShellTool Model-generated shell commands need OCI-container isolation.
ShellEnvironmentProvider The model needs the active shell family, operating system, working directory, and installed CLI versions.
ShellPolicy You want an allow-list or deny-list pre-filter before approval or execution.

Warning

Shell execution can modify files, launch processes, access credentials, and communicate with external systems. Use the least-privileged execution tier that supports the task.

Install the package

dotnet add package Microsoft.Agents.AI.Tools.Shell --prerelease

Use local shell and environment awareness

LocalShellExecutor supports stateless and persistent modes. ShellEnvironmentProvider probes the active environment and adds authoritative shell guidance to the agent context.

using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Tools.Shell;
using Microsoft.Extensions.AI;

var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";

// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());

const string Instructions = """
    You are an agent with a single tool: run_shell. Use it to satisfy the
    user's request. Do not describe what you would do — actually run the
    commands. Reply with the final answer derived from real output.
    """;

// --------------------------------------------------------------------
// 1. Stateless mode — each call gets a fresh shell.
// --------------------------------------------------------------------
Console.WriteLine("### Stateless mode\n");
await using (var statelessShell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless, AcknowledgeUnsafe = true }))
{
    var envProvider = new ShellEnvironmentProvider(statelessShell);
    var statelessAgent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions
    {
        ChatOptions = new()
        {
            ModelId = deploymentName,
            Instructions = Instructions,
            Tools = [statelessShell.AsAIFunction(requireApproval: false)],
        },
        AIContextProviders = [envProvider],
    });
// --------------------------------------------------------------------
// 2. Persistent mode — one shell, reused across calls. State carries.
// --------------------------------------------------------------------
Console.WriteLine("\n### Persistent mode\n");
await using (var persistentShell = new LocalShellExecutor(new() { Mode = ShellMode.Persistent, AcknowledgeUnsafe = true }))
{
    var envProvider = new ShellEnvironmentProvider(persistentShell);
    var persistentAgent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions
    {
        ChatOptions = new()
        {
            ModelId = deploymentName,
            Instructions = Instructions,
            Tools = [persistentShell.AsAIFunction(requireApproval: false)],
        },
        AIContextProviders = [envProvider],
    });

    var persistentSession = await persistentAgent.CreateSessionAsync();

    // State carries across calls in persistent mode: cd into temp, then
    // verify the next call sees the new CWD.
    Console.WriteLine(await persistentAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", persistentSession));
    Console.WriteLine();
    Console.WriteLine(await persistentAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it still matches the temp folder.", persistentSession));
    Console.WriteLine();

    // Same idea with an exported variable: set in one call, read in the next.
    Console.WriteLine(await persistentAgent.RunAsync("Set the environment variable DEMO_TOKEN to the value 'hello-world'.", persistentSession));
    Console.WriteLine();
    Console.WriteLine(await persistentAgent.RunAsync("Print the current value of DEMO_TOKEN. Tell me exactly what value the shell reports.", persistentSession));
    Console.WriteLine();

    PrintSnapshot(envProvider.CurrentSnapshot!);
}

ShellPolicy is also available for command pre-filtering. A dedicated runnable DockerShellExecutor sample isn't currently published.

Install the package

pip install agent-framework-tools --pre

The package installs psutil to terminate child process trees when an execution times out.

Use LocalShellTool

LocalShellTool runs commands directly on the host. It defaults to a persistent shell, a 30-second timeout, 64-KiB output truncation, working-directory confinement, and approval for every command.

import asyncio
from typing import Any

from agent_framework import Agent, Message
from agent_framework.openai import OpenAIChatClient
from agent_framework.tools import LocalShellTool
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()
async def main() -> None:
    print("=== OpenAI Agent with LocalShellTool Example ===")
    print("NOTE: Commands will execute on your local machine.\n")

    client = OpenAIChatClient(model="gpt-5.4-nano")

    async with LocalShellTool() as shell:
        agent = Agent(
            client=client,
            instructions="You are a helpful assistant that can run shell commands to help the user.",
            tools=[client.get_shell_tool(func=shell.as_function())],
        )

        query = "Use the shell tool to execute `python --version` and show only the command output."
        print(f"User: {query}")
        result = await run_with_approvals(query, agent)
        if isinstance(result, str):
            print(f"Agent: {result}\n")
            return
        if result.text:
            print(f"Agent: {result.text}\n")
        else:
            printed = False
            for message in result.messages:
                for content in message.contents:
                    if content.type == "function_result" and content.result:
                        print(f"Agent (tool output): {content.result}\n")
                        printed = True
            if not printed:
                print("Agent: (no text output returned)\n")


async def run_with_approvals(query: str, agent: Agent) -> Any:
    """Run the agent and handle shell approvals outside tool execution."""
    current_input: str | list[Any] = query

    while True:
        result = await agent.run(current_input)
        if not result.user_input_requests:
            return result

        next_input: list[Any] = [query]
        rejected = False
        for user_input_needed in result.user_input_requests:
            if user_input_needed.function_call is None:
                continue
            print(
                f"\nShell request: {user_input_needed.function_call.name}"
                f"\nArguments: {user_input_needed.function_call.arguments}"
            )
            user_approval = await asyncio.to_thread(input, "\nApprove shell command? (y/n): ")
            approved = user_approval.strip().lower() == "y"
            next_input.append(Message("assistant", [user_input_needed]))
            next_input.append(Message("user", [user_input_needed.to_function_approval_response(approved)]))
            if not approved:
                rejected = True
                break
        if rejected:
            print("\nShell command rejected. Stopping without additional approval prompts.")
            return "Shell command execution was rejected by user."
        current_input = next_input


if __name__ == "__main__":
    asyncio.run(main())

Use mode="stateless" when each call should run in a fresh process. Use the AGENT_FRAMEWORK_SHELL environment variable or the shell constructor argument to override the resolved shell.

Important

LocalShellTool isn't a sandbox. Approval is the primary security boundary. Disabling approval requires acknowledge_unsafe=True.

Restrict commands with ShellPolicy

ShellPolicy applies regular-expression allow and deny lists before execution. Deny rules take precedence.

import asyncio

from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework.tools import LocalShellTool, ShellPolicy
from dotenv import load_dotenv
load_dotenv()
async def main() -> None:
    client = OpenAIChatClient(model="gpt-5.4-nano")

    shell = LocalShellTool(
        mode="stateless",
        approval_mode="never_require",
        acknowledge_unsafe=True,
        policy=ShellPolicy(
            allowlist=[
                r"^ls(\s|$)",
                r"^pwd$",
                r"^cat\s[^|;&]+$",
                r"^git\s+(status|log|diff)(\s|$)",
                r"^python\s+--version$",
            ],
        ),
        timeout=10,
    )

    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."
        ),
        tools=[client.get_shell_tool(func=shell.as_function())],
    )

    query = "Summarise the current directory and print the Python version."
    print(f"User: {query}")
    result = await agent.run(query)
    print(f"Agent: {result.text}")

Warning

A command policy is a usability pre-filter, not a security boundary. Shell syntax, aliases, variables, interpreters, and encoded payloads can bypass simple pattern matching.

Add ShellEnvironmentProvider

ShellEnvironmentProvider probes the shell family, version, operating system, working directory, and selected CLI versions, then injects that information before the agent runs. The default probe list is git, node, python, and docker.

import asyncio

from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework.tools import (
    LocalShellTool,
    ShellEnvironmentProvider,
    ShellEnvironmentProviderOptions,
)
from dotenv import load_dotenv
load_dotenv()
def _print_snapshot(label: str, provider: ShellEnvironmentProvider) -> None:
    snapshot = provider.current_snapshot
    if snapshot is None:
        print(f"[{label}] no snapshot captured")
        return
    print(f"\n[{label}] snapshot:")
    print(f"  family            = {snapshot.family.value}")
    print(f"  os                = {snapshot.os_description}")
    print(f"  shell_version     = {snapshot.shell_version}")
    print(f"  working_directory = {snapshot.working_directory}")
    for tool, version in snapshot.tool_versions.items():
        print(f"  {tool:<17} = {version}")


async def _ask(agent: Agent, query: str) -> None:
    print(f"\nUser: {query}")
    result = await agent.run(query)
    if result.text:
        print(f"Agent: {result.text}")


async def main() -> None:
    client = OpenAIChatClient(model="gpt-5.4-nano")
    options = ShellEnvironmentProviderOptions(
        probe_tools=("git", "python", "uv", "node"),
    )

    print("=== stateless mode ===")
    async with LocalShellTool(
        mode="stateless",
        approval_mode="never_require",
        acknowledge_unsafe=True,
    ) as shell:
        provider = ShellEnvironmentProvider(shell, options)
        agent = Agent(
            client=client,
            instructions="Use the shell tool to answer the user's question.",
            tools=[client.get_shell_tool(func=shell.as_function())],
            context_providers=[provider],
        )
        await _ask(agent, "Show me the current working directory.")
        await _ask(agent, "Now `cd ..` then show the working directory again.")
        await _ask(agent, "Show the working directory once more — did `cd` persist?")
        _print_snapshot("stateless", provider)

    print("\n=== persistent mode ===")
    async with LocalShellTool(
        mode="persistent",
        confine_workdir=False,
        approval_mode="never_require",
        acknowledge_unsafe=True,
    ) as shell:
        provider = ShellEnvironmentProvider(shell, options)
        agent = Agent(
            client=client,
            instructions="Use the shell tool to answer the user's question.",
            tools=[client.get_shell_tool(func=shell.as_function())],
            context_providers=[provider],
        )
        await _ask(agent, "Show me the current working directory.")
        await _ask(agent, "Now `cd ..` then show the working directory again.")
        await _ask(agent, "Show the working directory once more — did `cd` persist?")
        _print_snapshot("persistent", provider)

Use DockerShellTool

DockerShellTool requires Docker or Podman on PATH. The defaults disable networking, run as a non-root user, use a read-only root filesystem, drop capabilities, limit memory to 512 MiB, and cap the container at 256 processes.

from agent_framework.tools import DockerShellTool

async with DockerShellTool(
    image="mcr.microsoft.com/azurelinux/base/core:3.0",
    approval_mode="never_require",
) as shell:
    result = await shell.run("uname -a && id")
    print(result.stdout)

The default image is mcr.microsoft.com/azurelinux/base/core:3.0. Pass docker_binary="podman" to use Podman. A dedicated runnable DockerShellTool sample isn't currently published.

Choose an execution tier

Scenario Tool Isolation boundary
Trusted development commands LocalShellTool Approval in the host process
Untrusted shell commands DockerShellTool OCI container with default isolation flags
Untrusted generated code without a shell Hyperlight CodeAct Hyperlight microVM

Go provides local shell execution and environment probing through tool/shelltool. See Use the local shell tool.

DockerShellTool guidance isn't currently available for Go.

Use shell tools with Harness Agent

Plain agents and HarnessAgent use the same two-part shell setup: register the executor's function as a tool, and add ShellEnvironmentProvider when the model should receive shell, operating-system, working-directory, and CLI-version context. HarnessAgent doesn't create or own a shell executor:

using System.IO;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Tools.Shell;
using Microsoft.Extensions.AI;

await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
{
    WorkingDirectory = Directory.GetCurrentDirectory(),
    Timeout = LocalShellExecutor.DefaultTimeout,
});

AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
    AIContextProviders = [new ShellEnvironmentProvider(shell)],
    ChatOptions = new ChatOptions
    {
        Tools = [shell.AsAIFunction(requireApproval: true)],
    },
});

AsAIFunction defaults to the name run_shell and requireApproval: true. LocalShellExecutor defaults to persistent mode, a 64-KiB cap per output stream, and no timeout; the example explicitly uses the recommended 30-second LocalShellExecutor.DefaultTimeout. ShellEnvironmentProviderOptions defaults to probing git, dotnet, node, python, and docker, with a five-second timeout per probe.

Create one persistent executor per user session and dispose it when the session ends. Don't share it across users or concurrent conversations because working directory, environment, shell history, background jobs, and the command queue are shared. ShellPolicy is only a pre-filter; keep approval enabled, use least-privileged credentials, and prefer DockerShellExecutor when commands require a stronger isolation boundary.

Shell tools are available from the prerelease Microsoft.Agents.AI.Tools.Shell package. HarnessAgent is available from Microsoft.Agents.AI.Harness.

For a plain agent, create the shell function with client.get_shell_tool(func=shell.as_function()) and add ShellEnvironmentProvider separately. create_harness_agent performs both steps when you pass shell_executor:

from agent_framework import create_harness_agent
from agent_framework.tools import LocalShellTool, ShellEnvironmentProviderOptions

async with LocalShellTool() as shell:
    agent = create_harness_agent(
        client=client,
        shell_executor=shell,
        shell_environment_provider_options=ShellEnvironmentProviderOptions(
            probe_tools=("git", "python"),
        ),
    )

    session = agent.create_session()
    response = await agent.run("Inspect the current repository.", session=session)

shell_executor is opt-in and must expose as_function(). The factory adds the shell tool and ShellEnvironmentProvider only when the client implements SupportsShellTool; otherwise it logs a warning and skips both. shell_environment_provider_options is optional and is used only with shell_executor.

LocalShellTool defaults to persistent mode, a 30-second timeout, 64-KiB combined output, working-directory re-anchoring, and approval_mode="always_require". Because Harness tool approval is enabled by default, pass an AgentSession to run. The caller owns the executor lifecycle; use async with or call close(), and create one persistent tool per user session. Don't share mutable shell state across users or concurrent conversations.

The host shell isn't a sandbox. Keep approval enabled, use least-privileged credentials, and use DockerShellTool for container isolation. Disabling approval requires approval_mode="never_require" and acknowledge_unsafe=True; ShellPolicy alone isn't a security boundary.

create_harness_agent is released in agent-framework-core. Shell integration is provided by the pre-release agent-framework-tools package and emits an ExperimentalWarning when enabled.

A packaged Go Harness isn't currently available. Compose the local shell tool and environment provider directly on a plain Go agent.