Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Tools.Shell/DockerShellExecutor.cs
T
westeyandGitHub bda40ba0e1 .NET: Add shell support to the HarnessAgent (#6005)
* Add shell support to the HarnessAgent

* Address PR comments

* Address PR comments
2026-05-21 17:25:33 +00:00

635 lines
26 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Sandboxed shell tool backed by a Docker (or compatible) container runtime.
/// </summary>
/// <remarks>
/// <para>
/// Exposes the same public surface as <see cref="LocalShellExecutor"/> but executes
/// commands inside a container. The container is intended to be the
/// security boundary, and the defaults bias toward a restrictive baseline
/// (<c>--network none</c>, non-root user, <c>--read-only</c> root filesystem,
/// <c>--cap-drop=ALL</c>, <c>--security-opt=no-new-privileges</c>, memory and
/// pids limits, <c>--tmpfs /tmp</c>). These are a best-effort starting point,
/// NOT a guarantee: the actual isolation you get depends on the host kernel,
/// the container runtime, the image, and any caller-supplied
/// <c>ExtraRunArgs</c>. Do not rely on this tool as your sole defense against
/// untrusted input. Approval gating via <see cref="AsAIFunction"/> is the
/// primary safety control; pair it with the precautions you would normally
/// apply when running adversarial code: review the model's output before
/// acting on it, run on a host you can afford to lose, monitor for resource
/// exhaustion, and consider stronger isolation (a dedicated VM, gVisor/Kata,
/// network segmentation) when stakes are high.
/// </para>
/// <para>
/// Persistent mode reuses <see cref="ShellSession"/> by launching
/// <c>docker exec -i &lt;container&gt; bash --noprofile --norc</c> as the
/// long-lived shell — the sentinel protocol works unchanged because the
/// host process is still a bash REPL connected over pipes. Stateless mode
/// runs each call in a fresh <c>docker run --rm</c>.
/// </para>
/// <para>
/// <b>Single-session ownership.</b> In persistent mode the executor owns a long-lived
/// container plus the bash REPL inside it. That container's filesystem, environment,
/// working directory, and any artifacts the agent has produced are visible to every
/// subsequent command, and a single stdin/stdout pipe serializes every call. A
/// persistent-mode <see cref="DockerShellExecutor"/> is therefore intended to be owned by
/// exactly one conversation / agent session — i.e., one user. Do not share one instance
/// across users, tenants, or concurrent conversations: their state leaks together inside
/// the container and commands queue behind each other. Create one executor per session,
/// dispose it when the session ends (disposal stops and removes the container), and in DI
/// scenarios register it with a per-session scope. If a shared instance is genuinely
/// required, use <see cref="ShellMode.Stateless"/>, which gives each call its own
/// throwaway <c>docker run --rm</c>.
/// </para>
/// </remarks>
public sealed class DockerShellExecutor : ShellExecutor
{
/// <summary>Default container image. A small Microsoft-maintained Linux base.</summary>
public const string DefaultImage = "mcr.microsoft.com/azurelinux/base/core:3.0";
/// <summary>Default Docker network mode (no network).</summary>
internal const string DefaultNetwork = DockerNetworkMode.None;
/// <summary>Default container memory limit, in bytes (512 MiB).</summary>
internal const long DefaultMemoryBytes = 512L * 1024 * 1024;
/// <summary>Default pids limit.</summary>
public const int DefaultPidsLimit = 256;
/// <summary>Default container working directory.</summary>
public const string DefaultContainerWorkdir = "/workspace";
/// <summary>
/// Recommended default per-command timeout (30 seconds). Pass this
/// explicitly via <see cref="DockerShellExecutorOptions.Timeout"/> to
/// opt in. Note that <see langword="null"/> (the property default) means
/// <em>no timeout</em>.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private readonly string _image;
private readonly ShellMode _mode;
private readonly string? _hostWorkdir;
private readonly string _containerWorkdir;
private readonly bool _mountReadonly;
private readonly string _network;
private readonly long _memoryBytes;
private readonly int _pidsLimit;
private readonly ContainerUser _user;
private readonly bool _readOnlyRoot;
private readonly IReadOnlyList<string> _extraRunArgs;
private readonly IReadOnlyDictionary<string, string> _env;
private readonly ShellPolicy _policy;
private readonly TimeSpan? _timeout;
private readonly int _maxOutputBytes;
private ShellSession? _session;
private bool _containerStarted;
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="DockerShellExecutor"/>
/// class with default options.
/// </summary>
public DockerShellExecutor() : this(new DockerShellExecutorOptions())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DockerShellExecutor"/> class.
/// </summary>
/// <param name="options">Configuration. <see langword="null"/> selects defaults.</param>
public DockerShellExecutor(DockerShellExecutorOptions options)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(options.Image);
if (options.MaxOutputBytes <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), $"{nameof(options.MaxOutputBytes)} must be positive.");
}
if (options.MemoryBytes is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), $"{nameof(options.MemoryBytes)} must be positive.");
}
this._image = options.Image;
this.ContainerName = options.ContainerName ?? GenerateContainerName();
this._mode = options.Mode;
this._hostWorkdir = options.HostWorkdir;
this._containerWorkdir = options.ContainerWorkdir ?? DefaultContainerWorkdir;
this._mountReadonly = options.MountReadonly;
this._network = options.Network ?? DefaultNetwork;
this._memoryBytes = options.MemoryBytes ?? DefaultMemoryBytes;
this._pidsLimit = options.PidsLimit;
this._user = options.User ?? ContainerUser.Default;
this._readOnlyRoot = options.ReadOnlyRoot;
this._extraRunArgs = options.ExtraRunArgs ?? Array.Empty<string>();
this._env = options.Environment ?? new Dictionary<string, string>();
this._policy = options.Policy ?? new ShellPolicy();
this._timeout = options.Timeout;
this._maxOutputBytes = options.MaxOutputBytes;
this.DockerBinary = options.DockerBinary ?? "docker";
}
/// <summary>Gets the container name (auto-generated when not specified at construction).</summary>
public string ContainerName { get; }
/// <summary>Gets the docker binary path.</summary>
public string DockerBinary { get; }
/// <summary>Eagerly start the container (and inner shell session in persistent mode).</summary>
public override async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await this._lifecycleLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (this._containerStarted)
{
return;
}
await this.StartContainerAsync(cancellationToken).ConfigureAwait(false);
this._containerStarted = true;
if (this._mode == ShellMode.Persistent)
{
var execArgv = BuildExecArgv(this.DockerBinary, this.ContainerName);
// BuildExecArgv already includes the bash flags
// (--noprofile --norc) at the end of the argv. We pass
// ShellKind.Sh here (not Bash) because Sh's
// PersistentArgv() returns an empty suffix and forwards
// ExtraArgv unchanged; Bash would re-append
// --noprofile/--norc and produce a duplicated argv.
var inner = new ResolvedShell(execArgv[0], ShellKind.Sh, ExtraArgv: execArgv.Skip(1).ToArray());
this._session = new ShellSession(
inner,
workingDirectory: null, // workdir is set on the container itself
confineWorkingDirectory: false,
environment: null,
cleanEnvironment: false,
maxOutputBytes: this._maxOutputBytes);
}
}
finally
{
_ = this._lifecycleLock.Release();
}
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
await this._lifecycleLock.WaitAsync().ConfigureAwait(false);
try
{
if (this._session is not null)
{
try { await this._session.DisposeAsync().ConfigureAwait(false); }
finally { this._session = null; }
}
if (this._containerStarted)
{
await this.StopContainerAsync().ConfigureAwait(false);
this._containerStarted = false;
}
}
finally
{
_ = this._lifecycleLock.Release();
}
this._lifecycleLock.Dispose();
}
/// <summary>Run a single command inside the container.</summary>
/// <exception cref="ShellCommandRejectedException">Thrown when the policy denies the command.</exception>
public override async Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default)
{
if (command is null)
{
throw new ArgumentNullException(nameof(command));
}
var decision = this._policy.Evaluate(new ShellRequest(command, this._containerWorkdir));
if (!decision.Allowed)
{
throw new ShellCommandRejectedException(
$"Command rejected by policy: {decision.Reason ?? "(unspecified)"}");
}
if (this._mode == ShellMode.Persistent)
{
if (this._session is null)
{
await this.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
return await this._session!.RunAsync(command, this._timeout, cancellationToken).ConfigureAwait(false);
}
return await this.RunStatelessAsync(command, cancellationToken).ConfigureAwait(false);
}
/// <summary>Format a byte count into the value passed to <c>docker --memory</c> (e.g. <c>536870912b</c>).</summary>
internal static string FormatMemoryBytes(long memoryBytes) =>
memoryBytes.ToString(System.Globalization.CultureInfo.InvariantCulture) + "b";
/// <summary>
/// Build the AIFunction for this tool.
/// </summary>
/// <remarks>
/// When <paramref name="requireApproval"/> is <see langword="true"/>
/// (the default), the returned function is wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
/// explicitly pass <see langword="false"/> to opt out of approval
/// gating. Container configuration alone is not a sufficient signal
/// to safely auto-execute model-generated commands — the
/// approval/policy decision belongs to the agent author.
/// </remarks>
/// <param name="name">Function name surfaced to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// <see langword="true"/> (the default) wraps the function in
/// <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="false"/> opts out and returns the raw function.
/// </param>
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
{
description ??=
"Execute a single shell command inside an isolated Docker container and return its " +
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
"(except an optional read-only workspace mount), and runs as a non-root user. " +
(this._mode == ShellMode.Persistent
? "PERSISTENT MODE: a single long-lived container handles every call; cd and exported variables persist."
: "STATELESS MODE: each call runs in a fresh container.");
var fn = AIFunctionFactory.Create(
async ([Description("The shell command to execute.")] string command,
CancellationToken cancellationToken) =>
{
try
{
var result = await this.RunAsync(command, cancellationToken).ConfigureAwait(false);
return result.FormatForModel();
}
catch (ShellCommandRejectedException ex)
{
// ex.Message already starts with "Command rejected by policy: ...".
return ex.Message;
}
},
new AIFunctionFactoryOptions { Name = name, Description = description });
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
}
/// <summary>
/// Probe whether the configured docker binary can be reached. Returns
/// <see langword="true"/> only if the binary exists on PATH and
/// <c>docker version</c> succeeds within ~5 seconds.
/// </summary>
public static async Task<bool> IsAvailableAsync(string binary = "docker", CancellationToken cancellationToken = default)
{
try
{
var psi = new ProcessStartInfo
{
FileName = binary,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("version");
psi.ArgumentList.Add("--format");
psi.ArgumentList.Add("{{.Server.Version}}");
using var proc = new Process { StartInfo = psi };
if (!proc.Start())
{
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await proc.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { proc.Kill(entireProcessTree: true); } catch { }
return false;
}
return proc.ExitCode == 0;
}
catch (Win32Exception)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
// ------------------------------------------------------------------
// Pure argv builders — kept side-effect-free so tests don't need Docker.
// ------------------------------------------------------------------
/// <summary>Build the <c>docker run -d</c> argv that starts the long-lived container.</summary>
public static IReadOnlyList<string> BuildRunArgv(
string binary,
string image,
string containerName,
ContainerUser user,
string network,
long memoryBytes,
int pidsLimit,
string workdir,
string? hostWorkdir,
bool mountReadonly,
bool readOnlyRoot,
IReadOnlyDictionary<string, string>? extraEnv,
IReadOnlyList<string>? extraArgs)
{
_ = Throw.IfNull(user);
var argv = new List<string>
{
binary,
"run",
"-d",
"--rm",
"--name", containerName,
"--user", user.ToString(),
"--network", network,
"--memory", FormatMemoryBytes(memoryBytes),
"--pids-limit", pidsLimit.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m",
"--workdir", workdir,
};
if (readOnlyRoot)
{
argv.Add("--read-only");
}
if (hostWorkdir is not null)
{
var ro = mountReadonly ? "ro" : "rw";
argv.Add("-v");
argv.Add($"{hostWorkdir}:{workdir}:{ro}");
}
if (extraEnv is not null)
{
foreach (var kv in extraEnv)
{
argv.Add("-e");
argv.Add($"{kv.Key}={kv.Value}");
}
}
if (extraArgs is not null)
{
foreach (var a in extraArgs) { argv.Add(a); }
}
argv.Add(image);
argv.Add("sleep");
argv.Add("infinity");
return argv;
}
/// <summary>
/// Build the <c>docker exec -i &lt;container&gt; bash --noprofile --norc</c> argv for
/// the persistent inner shell. Stateless callers should use
/// <see cref="BuildRunArgvStateless"/>; this method intentionally does
/// not produce a stand-alone command argv.
/// </summary>
public static IReadOnlyList<string> BuildExecArgv(string binary, string containerName)
{
return new List<string> { binary, "exec", "-i", containerName, "bash", "--noprofile", "--norc" };
}
private async Task StartContainerAsync(CancellationToken cancellationToken)
{
var argv = BuildRunArgv(
this.DockerBinary, this._image, this.ContainerName, this._user, this._network,
this._memoryBytes, this._pidsLimit, this._containerWorkdir, this._hostWorkdir,
this._mountReadonly, this._readOnlyRoot, this._env, this._extraRunArgs);
var (exit, _, stderr) = await RunDockerCommandAsync(argv, cancellationToken).ConfigureAwait(false);
if (exit != 0)
{
throw new DockerNotAvailableException(
$"Failed to start container ({exit}): {stderr.Trim()}");
}
}
private async Task StopContainerAsync()
{
var argv = new[] { this.DockerBinary, "rm", "-f", this.ContainerName };
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
_ = await RunDockerCommandAsync(argv, cts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is Win32Exception || ex is InvalidOperationException)
{
// Best-effort teardown.
}
}
private async Task<ShellResult> RunStatelessAsync(string command, CancellationToken cancellationToken)
{
var perCallName = GenerateContainerName();
var argv = new List<string>(this.BuildRunArgvStateless(perCallName));
argv.Add(this._image);
argv.Add("bash");
argv.Add("-c");
argv.Add(command);
var stopwatch = Stopwatch.StartNew();
var stdoutBuf = new HeadTailBuffer(this._maxOutputBytes);
var stderrBuf = new HeadTailBuffer(this._maxOutputBytes);
var psi = new ProcessStartInfo
{
FileName = argv[0],
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
for (var i = 1; i < argv.Count; i++) { psi.ArgumentList.Add(argv[i]); }
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuf.AppendLine(e.Data); } };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuf.AppendLine(e.Data); } };
try { _ = proc.Start(); }
catch (Win32Exception ex)
{
throw new IOException($"Failed to launch '{this.DockerBinary}': {ex.Message}", ex);
}
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
var timedOut = false;
using var timeoutCts = this._timeout is null
? new CancellationTokenSource()
: new CancellationTokenSource(this._timeout.Value);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
await proc.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
timedOut = true;
// Kill the running container by name; --rm reaps it.
await this.BestEffortKillContainerAsync(perCallName).ConfigureAwait(false);
try { await proc.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception ex) when (ex is InvalidOperationException || ex is Win32Exception) { }
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-driven cancellation: --rm only fires when PID 1 exits, so
// if we just propagate, the container keeps running indefinitely.
// Kill it explicitly before rethrowing so we don't leak containers.
await this.BestEffortKillContainerAsync(perCallName).ConfigureAwait(false);
try { await proc.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception ex) when (ex is InvalidOperationException || ex is Win32Exception) { }
throw;
}
proc.WaitForExit();
stopwatch.Stop();
var (sout, soutT) = stdoutBuf.ToFinalString();
var (serr, serrT) = stderrBuf.ToFinalString();
return new ShellResult(
Stdout: sout,
Stderr: serr,
ExitCode: timedOut ? 124 : proc.ExitCode,
Duration: stopwatch.Elapsed,
Truncated: soutT || serrT,
TimedOut: timedOut);
}
private List<string> BuildRunArgvStateless(string perCallName)
{
var argv = new List<string>
{
this.DockerBinary,
"run", "--rm", "-i",
"--name", perCallName,
"--user", this._user.ToString(),
"--network", this._network,
"--memory", FormatMemoryBytes(this._memoryBytes),
"--pids-limit", this._pidsLimit.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m",
"--workdir", this._containerWorkdir,
};
if (this._readOnlyRoot) { argv.Add("--read-only"); }
if (this._hostWorkdir is not null)
{
var ro = this._mountReadonly ? "ro" : "rw";
argv.Add("-v");
argv.Add($"{this._hostWorkdir}:{this._containerWorkdir}:{ro}");
}
foreach (var kv in this._env)
{
argv.Add("-e");
argv.Add($"{kv.Key}={kv.Value}");
}
foreach (var a in this._extraRunArgs) { argv.Add(a); }
return argv;
}
private async Task BestEffortKillContainerAsync(string containerName)
{
try
{
using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = await RunDockerCommandAsync(
new[] { this.DockerBinary, "kill", "--signal", "KILL", containerName }, killCts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is Win32Exception || ex is InvalidOperationException)
{
// best-effort: container may already be gone
}
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunDockerCommandAsync(
IReadOnlyList<string> argv, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo
{
FileName = argv[0],
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
for (var i = 1; i < argv.Count; i++) { psi.ArgumentList.Add(argv[i]); }
// Cap helper-command output at 1 MiB. These commands (`docker version`,
// `docker kill`, `docker pull`) shouldn't produce more than that, but a
// chatty `docker pull` progress stream can easily run into hundreds of
// KiB; bound the buffer so we never exhaust memory on misbehaviour.
const int HelperOutputCap = 1 * 1024 * 1024;
var stdoutBuf = new HeadTailBuffer(HelperOutputCap);
var stderrBuf = new HeadTailBuffer(HelperOutputCap);
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuf.AppendLine(e.Data); } };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuf.AppendLine(e.Data); } };
_ = proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
proc.WaitForExit();
return (proc.ExitCode, stdoutBuf.ToFinalString().text, stderrBuf.ToFinalString().text);
}
private static string GenerateContainerName()
{
var bytes = new byte[6];
#if NET6_0_OR_GREATER
RandomNumberGenerator.Fill(bytes);
#else
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
#endif
#pragma warning disable CA1308
return "af-shell-" + Convert.ToHexString(bytes).ToLowerInvariant();
#pragma warning restore CA1308
}
}
/// <summary>
/// Thrown when the configured docker (or compatible) binary cannot start a
/// container — typically because the daemon isn't running, the image
/// can't be pulled, or the binary isn't on PATH.
/// </summary>
public sealed class DockerNotAvailableException : Exception
{
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
public DockerNotAvailableException() { }
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
/// <param name="message">The exception message.</param>
public DockerNotAvailableException(string message) : base(message) { }
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception.</param>
public DockerNotAvailableException(string message, Exception inner) : base(message, inner) { }
}