From 57938f49ddf309ee4819eae808d0f256725ed7ee Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:59:32 -0800 Subject: [PATCH 01/12] Added ShellTool and LocalShellExecutor --- dotnet/agent-framework-dotnet.slnx | 2 + .../Shell/ShellCallContent.cs | 59 ++++ .../Shell/ShellCommandOutput.cs | 44 +++ .../Shell/ShellExecutor.cs | 36 ++ .../Shell/ShellExecutorOutput.cs | 48 +++ .../Shell/ShellResultContent.cs | 48 +++ .../Shell/ShellTool.cs | 187 ++++++++++ .../Shell/ShellToolExtensions.cs | 58 ++++ .../Shell/ShellToolOptions.cs | 141 ++++++++ .../LocalShellExecutor.cs | 244 +++++++++++++ .../Microsoft.Agents.AI.Shell.Local.csproj | 31 ++ .../Shell/ShellToolOptionsTests.cs | 137 ++++++++ .../Shell/ShellToolTests.cs | 322 ++++++++++++++++++ .../LocalShellExecutorTests.cs | 245 +++++++++++++ ...nts.AI.Shell.Local.IntegrationTests.csproj | 11 + 15 files changed, 1613 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Shell.Local/Microsoft.Agents.AI.Shell.Local.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/Microsoft.Agents.AI.Shell.Local.IntegrationTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 3c8c4ce00f..7a88f1b32a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -406,6 +406,7 @@ + @@ -449,5 +450,6 @@ + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs new file mode 100644 index 0000000000..3f20b0a733 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a shell command execution request. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class ShellCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this shell call. + /// The commands to execute. + [JsonConstructor] + public ShellCallContent(string callId, IReadOnlyList commands) + { + CallId = Throw.IfNull(callId); + Commands = Throw.IfNull(commands); + } + + /// + /// Gets the unique identifier for this shell call. + /// + public string CallId { get; } + + /// + /// Gets the commands to execute. + /// + public IReadOnlyList Commands { get; } + + /// + /// Gets or sets the timeout in milliseconds. + /// + /// + /// If not specified, the value will be used. + /// + public int? TimeoutInMilliseconds { get; set; } + + /// + /// Gets or sets the maximum output length in bytes. + /// + /// + /// If not specified, the value will be used. + /// + public int? MaxOutputLength { get; set; } + + /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => + $"ShellCall = {CallId}, Commands = {Commands.Count}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs new file mode 100644 index 0000000000..f13703a475 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Represents the output of a single shell command execution. +/// +public sealed class ShellCommandOutput +{ + /// + /// Gets or sets the command that was executed. + /// + public string? Command { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string? StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string? StandardError { get; set; } + + /// + /// Gets or sets the exit code. Null if the command timed out or failed to start. + /// + public int? ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether the command execution timed out. + /// + public bool IsTimedOut { get; set; } + + /// + /// Gets or sets a value indicating whether the output was truncated due to MaxOutputLength. + /// + public bool IsTruncated { get; set; } + + /// + /// Gets or sets an error message if the command failed to start. + /// + public string? Error { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs new file mode 100644 index 0000000000..d9342b54a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for shell command execution. +/// +/// +/// +/// Implementations of this class handle the actual execution of shell commands. +/// The base class is designed to be extensible for different execution contexts +/// (local, SSH, container, etc.). +/// +/// +/// Executors return raw objects, which are +/// converted to by . +/// +/// +public abstract class ShellExecutor +{ + /// + /// Executes the specified shell commands. + /// + /// The commands to execute. + /// The options controlling execution behavior. + /// The cancellation token. + /// Raw output data for each command. + public abstract Task> ExecuteAsync( + IReadOnlyList commands, + ShellToolOptions options, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs new file mode 100644 index 0000000000..702c713e87 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Raw output from shell executor (simple data class). +/// +/// +/// This class is used internally by implementations +/// to return raw data. converts these to . +/// +public sealed class ShellExecutorOutput +{ + /// + /// Gets or sets the command that was executed. + /// + public string? Command { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string? StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string? StandardError { get; set; } + + /// + /// Gets or sets the exit code. Null if the command timed out or failed to start. + /// + public int? ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether the command execution timed out. + /// + public bool IsTimedOut { get; set; } + + /// + /// Gets or sets a value indicating whether the output was truncated due to MaxOutputLength. + /// + public bool IsTruncated { get; set; } + + /// + /// Gets or sets an error message if the command failed to start. + /// + public string? Error { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs new file mode 100644 index 0000000000..592f93f2ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the result of a shell command execution. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class ShellResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The call ID matching the . + /// The output for each command executed. + [JsonConstructor] + public ShellResultContent(string callId, IReadOnlyList output) + { + CallId = Throw.IfNull(callId); + Output = Throw.IfNull(output); + } + + /// + /// Gets the call ID matching the . + /// + public string CallId { get; } + + /// + /// Gets the output for each command executed. + /// + public IReadOnlyList Output { get; } + + /// + /// Gets or sets the maximum output length that was applied. + /// + public int? MaxOutputLength { get; set; } + + /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => + $"ShellResult = {CallId}, Outputs = {Output.Count}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs new file mode 100644 index 0000000000..d5c81b8628 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A tool that executes shell commands with security controls. +/// +/// +/// +/// ShellTool provides a secure way to execute shell commands with configurable +/// allowlist/denylist patterns, privilege escalation prevention, and output limits. +/// +/// +/// Use the extension method to convert +/// this tool to an for use with AI agents. +/// +/// +public class ShellTool : AITool +{ + private readonly ShellToolOptions _options; + private readonly ShellExecutor _executor; + + private static readonly string[] PrivilegeEscalationCommands = + [ + "sudo", + "su", + "runas", + "doas", + "pkexec" + ]; + + /// + /// Initializes a new instance of the class. + /// + /// The executor to use for command execution. + /// Optional configuration options. + /// is null. + public ShellTool(ShellExecutor executor, ShellToolOptions? options = null) + { + _executor = Throw.IfNull(executor); + _options = options ?? new ShellToolOptions(); + } + + /// + /// Gets the name of the tool. + /// + public override string Name => "shell"; + + /// + /// Gets the description of the tool. + /// + public override string Description => + "Execute shell commands. Returns stdout, stderr, and exit code for each command."; + + /// + /// Gets the configured options for this shell tool. + /// + public ShellToolOptions Options => _options; + + /// + /// Executes shell commands and returns result content. + /// + /// The shell call content containing commands to execute. + /// The cancellation token. + /// The result content containing output for each command. + /// is null. + /// A command is blocked by security rules. + public async Task ExecuteAsync( + ShellCallContent callContent, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(callContent); + + // Apply call-specific overrides + var effectiveOptions = ApplyOverrides(_options, callContent); + + // Validate all commands first + foreach (var command in callContent.Commands) + { + ValidateCommand(command); + } + + // Execute via the executor + var rawOutputs = await _executor.ExecuteAsync( + callContent.Commands, + effectiveOptions, + cancellationToken).ConfigureAwait(false); + + // Convert to content + var outputs = rawOutputs.Select(r => new ShellCommandOutput + { + Command = r.Command, + StandardOutput = r.StandardOutput, + StandardError = r.StandardError, + ExitCode = r.ExitCode, + IsTimedOut = r.IsTimedOut, + IsTruncated = r.IsTruncated, + Error = r.Error + }).ToList(); + + return new ShellResultContent(callContent.CallId, outputs) + { + MaxOutputLength = effectiveOptions.MaxOutputLength + }; + } + + private static ShellToolOptions ApplyOverrides(ShellToolOptions baseOptions, ShellCallContent callContent) + { + // If no overrides specified, use base options + if (callContent.TimeoutInMilliseconds is null && callContent.MaxOutputLength is null) + { + return baseOptions; + } + + // Create effective options with overrides + return new ShellToolOptions + { + WorkingDirectory = baseOptions.WorkingDirectory, + TimeoutInMilliseconds = callContent.TimeoutInMilliseconds ?? baseOptions.TimeoutInMilliseconds, + MaxOutputLength = callContent.MaxOutputLength ?? baseOptions.MaxOutputLength, + AllowedCommands = baseOptions.AllowedCommands, + DeniedCommands = baseOptions.DeniedCommands, + BlockPrivilegeEscalation = baseOptions.BlockPrivilegeEscalation, + Shell = baseOptions.Shell + }; + } + + private void ValidateCommand(string command) + { + // 1. Check denylist first (priority over allowlist) + if (_options.CompiledDeniedPatterns is { Count: > 0 }) + { + foreach (var pattern in _options.CompiledDeniedPatterns) + { + if (pattern.IsMatch(command)) + { + throw new InvalidOperationException( + "Command blocked by denylist pattern."); + } + } + } + + // 2. Check allowlist (if configured) + if (_options.CompiledAllowedPatterns is { Count: > 0 }) + { + bool allowed = _options.CompiledAllowedPatterns + .Any(p => p.IsMatch(command)); + if (!allowed) + { + throw new InvalidOperationException( + "Command not in allowlist."); + } + } + + // 3. Check privilege escalation + if (_options.BlockPrivilegeEscalation && + ContainsPrivilegeEscalation(command)) + { + throw new InvalidOperationException( + "Privilege escalation commands are blocked."); + } + } + + private static bool ContainsPrivilegeEscalation(string command) + { + var trimmed = command.TrimStart(); + + foreach (var dangerous in PrivilegeEscalationCommands) + { + if (trimmed.StartsWith(dangerous + " ", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals(dangerous, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs new file mode 100644 index 0000000000..101bbe882c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Extension methods for . +/// +public static class ShellToolExtensions +{ + /// + /// Converts a to an for use with agents. + /// + /// The shell tool to convert. + /// Optional override for timeout in milliseconds. If null, uses . + /// Optional override for max output length. If null, uses . + /// An that wraps the shell tool. + /// is null. + /// + /// + /// The returned accepts a commands parameter which is an array of + /// shell commands to execute. The function returns a containing + /// the output for each command. + /// + /// + public static AIFunction AsAIFunction( + this ShellTool shellTool, + int? timeoutInMilliseconds = null, + int? maxOutputLength = null) + { + _ = Throw.IfNull(shellTool); + + return AIFunctionFactory.Create( + async ( + [Description("List of shell commands to execute")] + string[] commands, + CancellationToken cancellationToken) => + { + var callContent = new ShellCallContent( + Guid.NewGuid().ToString(), + commands) + { + TimeoutInMilliseconds = timeoutInMilliseconds, + MaxOutputLength = maxOutputLength + }; + + return await shellTool.ExecuteAsync(callContent, cancellationToken).ConfigureAwait(false); + }, + name: shellTool.Name, + description: shellTool.Description); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs new file mode 100644 index 0000000000..217433d378 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI; + +/// +/// Options for configuring shell tool behavior and security. +/// +public class ShellToolOptions +{ + private IList? _allowedCommands; + private IList? _deniedCommands; + private IReadOnlyList? _compiledAllowedPatterns; + private IReadOnlyList? _compiledDeniedPatterns; + + /// + /// Gets or sets the working directory for command execution. + /// When null, uses the current working directory. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets or sets the command execution timeout in milliseconds. + /// Default: 60000 (60 seconds). + /// + public int TimeoutInMilliseconds { get; set; } = 60000; + + /// + /// Gets or sets the maximum output size in bytes. + /// Default: 51200 (50 KB). + /// + public int MaxOutputLength { get; set; } = 51200; + + /// + /// Gets or sets the allowlist of permitted command patterns. + /// Supports regex patterns. Denylist takes priority over allowlist. + /// + /// + /// + /// When configured, only commands matching at least one of the patterns will be allowed to execute. + /// If a command matches a denylist pattern, it will be blocked regardless of allowlist matches. + /// + /// + /// Patterns can be regular expressions (e.g., ^git\s) or literal strings. + /// Invalid regex patterns are automatically treated as literal strings. + /// + /// + public IList? AllowedCommands + { + get => _allowedCommands; + set + { + _allowedCommands = value; + _compiledAllowedPatterns = CompilePatterns(value); + } + } + + /// + /// Gets or sets the denylist of blocked command patterns. + /// Supports regex patterns. Denylist takes priority over allowlist. + /// + /// + /// + /// Commands matching any denylist pattern will be blocked, even if they also match an allowlist pattern. + /// + /// + /// Patterns can be regular expressions (e.g., rm\s+-rf) or literal strings. + /// Invalid regex patterns are automatically treated as literal strings. + /// + /// + public IList? DeniedCommands + { + get => _deniedCommands; + set + { + _deniedCommands = value; + _compiledDeniedPatterns = CompilePatterns(value); + } + } + + /// + /// Gets or sets a value indicating whether privilege escalation commands are blocked. + /// Default: true. + /// + /// + /// When enabled, commands starting with sudo, su, runas, doas, or pkexec + /// will be blocked. + /// + public bool BlockPrivilegeEscalation { get; set; } = true; + + /// + /// Gets or sets the shell executable to use. + /// When null, auto-detects based on OS (cmd.exe on Windows, /bin/sh on Unix). + /// + public string? Shell { get; set; } + + /// + /// Gets the compiled allowlist patterns for internal use. + /// + internal IReadOnlyList? CompiledAllowedPatterns => _compiledAllowedPatterns; + + /// + /// Gets the compiled denylist patterns for internal use. + /// + internal IReadOnlyList? CompiledDeniedPatterns => _compiledDeniedPatterns; + + private static List? CompilePatterns(IList? patterns) + { + if (patterns is null || patterns.Count == 0) + { + return null; + } + + var compiled = new List(patterns.Count); + foreach (var pattern in patterns) + { + // Try-catch is used here because there is no way to validate a regex pattern + // without attempting to compile it. + try + { + compiled.Add(new Regex( + pattern, + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1))); + } + catch (ArgumentException) + { + // Treat as literal string match + compiled.Add(new Regex( + Regex.Escape(pattern), + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1))); + } + } + + return compiled; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs new file mode 100644 index 0000000000..2922f3b8be --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Executes shell commands on the local machine using the native shell. +/// +/// +/// +/// On Windows, commands are executed using cmd.exe /c. +/// On Unix-like systems, commands are executed using /bin/sh -c. +/// +/// +/// The shell can be overridden using . +/// +/// +public class LocalShellExecutor : ShellExecutor +{ + /// + public override async Task> ExecuteAsync( + IReadOnlyList commands, + ShellToolOptions options, + CancellationToken cancellationToken = default) + { + var results = new List(commands.Count); + + foreach (var command in commands) + { + var result = await ExecuteSingleCommandAsync( + command, options, cancellationToken).ConfigureAwait(false); + results.Add(result); + } + + return results; + } + + private static async Task ExecuteSingleCommandAsync( + string command, + ShellToolOptions options, + CancellationToken cancellationToken) + { + var (shell, args) = GetShellAndArgs(command, options.Shell); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = shell, + Arguments = args, + WorkingDirectory = options.WorkingDirectory ?? Environment.CurrentDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + bool stdoutTruncated = false; + bool stderrTruncated = false; + var outputLock = new object(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) + { + lock (outputLock) + { + if (stdout.Length < options.MaxOutputLength) + { + if (stdout.Length + e.Data.Length + 1 > options.MaxOutputLength) + { + int remainingLength = options.MaxOutputLength - stdout.Length; + stdout.Append(e.Data.Substring(0, remainingLength)); + stdoutTruncated = true; + } + else + { + stdout.AppendLine(e.Data); + } + } + else + { + stdoutTruncated = true; + } + } + } + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + lock (outputLock) + { + if (stderr.Length < options.MaxOutputLength) + { + if (stderr.Length + e.Data.Length + 1 > options.MaxOutputLength) + { + int remainingLength = options.MaxOutputLength - stderr.Length; + stderr.Append(e.Data.Substring(0, remainingLength)); + stderrTruncated = true; + } + else + { + stderr.AppendLine(e.Data); + } + } + else + { + stderrTruncated = true; + } + } + } + }; + + using var timeoutCts = new CancellationTokenSource(options.TimeoutInMilliseconds); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, timeoutCts.Token); + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await WaitForExitAsync(process, linkedCts.Token).ConfigureAwait(false); + + return new ShellExecutorOutput + { + Command = command, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString(), + ExitCode = process.ExitCode, + IsTimedOut = false, + IsTruncated = stdoutTruncated || stderrTruncated + }; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + TryKillProcess(process); + return new ShellExecutorOutput + { + Command = command, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString(), + IsTimedOut = true, + IsTruncated = stdoutTruncated || stderrTruncated + }; + } + catch (OperationCanceledException) + { + // Cancellation was requested by the user + TryKillProcess(process); + throw; + } + catch (Exception ex) + { + return new ShellExecutorOutput + { + Command = command, + Error = ex.Message + }; + } + } + + private static (string shell, string args) GetShellAndArgs( + string command, string? shellOverride) + { + if (!string.IsNullOrEmpty(shellOverride)) + { + // When shell is overridden, pass command as single argument + return (shellOverride!, command); + } + +#if NET + if (OperatingSystem.IsWindows()) + { + return ("cmd.exe", $"/c {command}"); + } + + return ("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\""); +#else + // For .NET Framework and .NET Standard, use runtime check + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + return ("cmd.exe", $"/c {command}"); + } + + return ("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\""); +#endif + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { +#if NET + process.Kill(entireProcessTree: true); +#else + process.Kill(); +#endif + } + } + catch + { + // Best effort - process may have already exited + } + } + + private static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken) + { +#if NET + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); +#else + // Polyfill for .NET Framework and .NET Standard + var tcs = new TaskCompletionSource(); + + process.EnableRaisingEvents = true; + process.Exited += (sender, args) => tcs.TrySetResult(true); + + if (process.HasExited) + { + tcs.TrySetResult(true); + } + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + await tcs.Task.ConfigureAwait(false); + } +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/Microsoft.Agents.AI.Shell.Local.csproj b/dotnet/src/Microsoft.Agents.AI.Shell.Local/Microsoft.Agents.AI.Shell.Local.csproj new file mode 100644 index 0000000000..2d8782a984 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/Microsoft.Agents.AI.Shell.Local.csproj @@ -0,0 +1,31 @@ + + + + preview + $(NoWarn);MEAI001 + + + + true + true + true + true + + + + + + + + + + + Microsoft Agent Framework Shell Local + Provides local shell command execution for Microsoft Agent Framework. + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs new file mode 100644 index 0000000000..8fb0e3cffb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public class ShellToolOptionsTests +{ + [Fact] + public void Constructor_WithDefaults_HasExpectedValues() + { + // Arrange & Act + var options = new ShellToolOptions(); + + // Assert + Assert.Null(options.WorkingDirectory); + Assert.Equal(60000, options.TimeoutInMilliseconds); + Assert.Equal(51200, options.MaxOutputLength); + Assert.Null(options.AllowedCommands); + Assert.Null(options.DeniedCommands); + Assert.True(options.BlockPrivilegeEscalation); + Assert.Null(options.Shell); + } + + [Fact] + public void AllowedCommands_WithValidRegexPatterns_CompilesSuccessfully() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List { "^git\\s", "^npm\\s", "^dotnet\\s" } + }; + + // Assert + Assert.NotNull(options.CompiledAllowedPatterns); + Assert.Equal(3, options.CompiledAllowedPatterns.Count); + } + + [Fact] + public void AllowedCommands_WithInvalidRegex_TreatsAsLiteralString() + { + // Arrange - "[" is an invalid regex pattern + var options = new ShellToolOptions + { + AllowedCommands = new List { "[invalid" } + }; + + // Assert - Should not throw, should treat as literal + Assert.NotNull(options.CompiledAllowedPatterns); + Assert.Single(options.CompiledAllowedPatterns); + + // The literal "[invalid" should be escaped and match exactly + Assert.Matches(options.CompiledAllowedPatterns[0], "[invalid"); + Assert.DoesNotMatch(options.CompiledAllowedPatterns[0], "invalid"); + } + + [Fact] + public void DeniedCommands_WithValidRegexPatterns_CompilesSuccessfully() + { + // Arrange + var options = new ShellToolOptions + { + DeniedCommands = new List { @"rm\s+-rf", "chmod", "chown" } + }; + + // Assert + Assert.NotNull(options.CompiledDeniedPatterns); + Assert.Equal(3, options.CompiledDeniedPatterns.Count); + } + + [Fact] + public void CompiledPatterns_WithMixedCaseInput_MatchesCaseInsensitively() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List { "^GIT" } + }; + + // Assert + Assert.NotNull(options.CompiledAllowedPatterns); + Assert.Matches(options.CompiledAllowedPatterns[0], "git status"); + Assert.Matches(options.CompiledAllowedPatterns[0], "GIT status"); + Assert.Matches(options.CompiledAllowedPatterns[0], "Git status"); + } + + [Fact] + public void CompiledAllowedPatterns_WithEmptyList_ReturnsNull() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List() + }; + + // Assert + Assert.Null(options.CompiledAllowedPatterns); + } + + [Fact] + public void CompiledAllowedPatterns_WithNullList_ReturnsNull() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = null + }; + + // Assert + Assert.Null(options.CompiledAllowedPatterns); + } + + [Fact] + public void AllowedCommands_WhenUpdated_RecompilesPatterns() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List { "^git" } + }; + + // Assert initial state + Assert.NotNull(options.CompiledAllowedPatterns); + Assert.Single(options.CompiledAllowedPatterns); + + // Act - Update the list + options.AllowedCommands = new List { "^npm", "^yarn" }; + + // Assert - Patterns should be updated + Assert.NotNull(options.CompiledAllowedPatterns); + Assert.Equal(2, options.CompiledAllowedPatterns.Count); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs new file mode 100644 index 0000000000..f59e61e10c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public class ShellToolTests +{ + private static readonly string[] s_rmRfCommand = ["rm -rf /"]; + private static readonly string[] s_curlCommand = ["curl http://example.com"]; + private static readonly string[] s_gitStatusCommand = ["git status"]; + private static readonly string[] s_rmFileCommand = ["rm file.txt"]; + private static readonly string[] s_sudoAptInstallCommand = ["sudo apt install"]; + private static readonly string[] s_echoHelloCommand = ["echo hello"]; + private static readonly string[] s_testCommand = ["test"]; + private static readonly string[] s_mixedCommands = ["safe command", "dangerous command"]; + + private readonly Mock _executorMock; + + public ShellToolTests() + { + _executorMock = new Mock(); + _executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new List + { + new() { Command = "test", StandardOutput = "output", ExitCode = 0 } + }); + } + + [Fact] + public void Constructor_WithNullExecutor_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new ShellTool(null!)); + } + + [Fact] + public void Name_WhenAccessed_ReturnsShell() + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + + // Assert + Assert.Equal("shell", tool.Name); + } + + [Fact] + public void Description_WhenAccessed_ReturnsNonEmptyString() + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(tool.Description)); + } + + [Fact] + public async Task ExecuteAsync_WithNullCallContent_ThrowsArgumentNullException() + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + tool.ExecuteAsync(null!)); + } + + [Fact] + public async Task ExecuteAsync_WithCommandMatchingDenylist_ThrowsInvalidOperationException() + { + // Arrange + var options = new ShellToolOptions + { + DeniedCommands = new List { @"rm\s+-rf" } + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_rmRfCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("DENYLIST", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithCommandNotMatchingAllowlist_ThrowsInvalidOperationException() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List { "^git\\s", "^npm\\s" } + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_curlCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("ALLOWLIST", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithCommandMatchingAllowlist_ReturnsResult() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = new List { "^git\\s" } + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_gitStatusCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + Assert.Equal("call-1", result.CallId); + } + + [Fact] + public async Task ExecuteAsync_WithCommandMatchingBothLists_PrioritizesDenylist() + { + // Arrange - Command matches both allowlist and denylist + var options = new ShellToolOptions + { + AllowedCommands = new List { ".*" }, // Allow everything + DeniedCommands = new List { "rm" } // But deny rm + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_rmFileCommand); + + // Act & Assert - Denylist should win + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("DENYLIST", ex.Message.ToUpperInvariant()); + } + + [Theory] + [InlineData("sudo apt install")] + [InlineData("SUDO apt install")] + [InlineData(" sudo apt install")] + [InlineData("su -")] + [InlineData("runas /user:admin cmd")] + [InlineData("doas command")] + [InlineData("pkexec command")] + public async Task ExecuteAsync_WithPrivilegeEscalationCommand_ThrowsInvalidOperationException(string command) + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("PRIVILEGE ESCALATION", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithPrivilegeEscalationDisabled_AllowsSudoCommands() + { + // Arrange + var options = new ShellToolOptions + { + BlockPrivilegeEscalation = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_sudoAptInstallCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Theory] + [InlineData("sudoku game")] + [InlineData("resume.txt")] + [InlineData("dosomething")] + public async Task ExecuteAsync_WithSimilarButSafeCommands_ReturnsResult(string command) + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutput() + { + // Arrange + var expectedOutput = new List + { + new() { Command = "echo hello", StandardOutput = "hello\n", ExitCode = 0 } + }; + _executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedOutput); + + var tool = new ShellTool(_executorMock.Object); + var callContent = new ShellCallContent("call-1", s_echoHelloCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.Equal("call-1", result.CallId); + Assert.Single(result.Output); + Assert.Equal("echo hello", result.Output[0].Command); + Assert.Equal("hello\n", result.Output[0].StandardOutput); + Assert.Equal(0, result.Output[0].ExitCode); + } + + [Fact] + public async Task ExecuteAsync_WithTimeoutOverride_AppliesOverrideValue() + { + // Arrange + var baseOptions = new ShellToolOptions + { + TimeoutInMilliseconds = 60000 + }; + ShellToolOptions? capturedOptions = null; + _executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ShellToolOptions, CancellationToken>((_, opts, _) => + capturedOptions = opts) + .ReturnsAsync(new List()); + + var tool = new ShellTool(_executorMock.Object, baseOptions); + var callContent = new ShellCallContent("call-1", s_testCommand) + { + TimeoutInMilliseconds = 30000 + }; + + // Act + await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(capturedOptions); + Assert.Equal(30000, capturedOptions.TimeoutInMilliseconds); + } + + [Fact] + public async Task ExecuteAsync_WithMaxOutputLengthOverride_AppliesOverrideValue() + { + // Arrange + var baseOptions = new ShellToolOptions + { + MaxOutputLength = 51200 + }; + ShellToolOptions? capturedOptions = null; + _executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ShellToolOptions, CancellationToken>((_, opts, _) => + capturedOptions = opts) + .ReturnsAsync(new List()); + + var tool = new ShellTool(_executorMock.Object, baseOptions); + var callContent = new ShellCallContent("call-1", s_testCommand) + { + MaxOutputLength = 10240 + }; + + // Act + await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(capturedOptions); + Assert.Equal(10240, capturedOptions.MaxOutputLength); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleCommands_ValidatesAllBeforeExecution() + { + // Arrange + var options = new ShellToolOptions + { + DeniedCommands = new List { "dangerous" } + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_mixedCommands); + + // Act & Assert - Should fail on second command before executing any + await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + + // Verify executor was never called + _executorMock.Verify( + e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs new file mode 100644 index 0000000000..c8438ceeb6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; + +namespace Microsoft.Agents.AI.Shell.Local.IntegrationTests; + +/// +/// Integration tests for . +/// +public class LocalShellExecutorTests +{ + private static readonly string[] s_nonExistentCommand = ["this_command_does_not_exist_12345"]; + private static readonly string[] s_powershellCommand = ["-Command Write-Output 'hello'"]; + + private readonly LocalShellExecutor _executor; + private readonly ShellToolOptions _options; + + public LocalShellExecutorTests() + { + _executor = new LocalShellExecutor(); + _options = new ShellToolOptions + { + TimeoutInMilliseconds = 30000, + MaxOutputLength = 51200 + }; + } + + [Fact] + public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutput() + { + // Arrange + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "echo hello" + : "echo hello"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, _options); + + // Assert + Assert.Single(results); + Assert.Equal(command, results[0].Command); + Assert.Equal(0, results[0].ExitCode); + Assert.Contains("hello", results[0].StandardOutput); + Assert.False(results[0].IsTimedOut); + } + + [Fact] + public async Task ExecuteAsync_WithNonZeroExitCode_CapturesExitCode() + { + // Arrange + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd /c exit 42" + : "exit 42"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, _options); + + // Assert + Assert.Single(results); + Assert.Equal(42, results[0].ExitCode); + } + + [Fact] + public async Task ExecuteAsync_WithStderrOutput_CapturesStandardError() + { + // Arrange + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd /c echo error message 1>&2" + : "echo error message >&2"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, _options); + + // Assert + Assert.Single(results); + Assert.Contains("ERROR", results[0].StandardError?.ToUpperInvariant() ?? string.Empty); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequence() + { + // Arrange + string[] commands = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ["echo first", "echo second", "echo third"] + : ["echo first", "echo second", "echo third"]; + + // Act + var results = await _executor.ExecuteAsync(commands, _options); + + // Assert + Assert.Equal(3, results.Count); + Assert.Contains("first", results[0].StandardOutput); + Assert.Contains("second", results[1].StandardOutput); + Assert.Contains("third", results[2].StandardOutput); + } + + [Fact] + public async Task ExecuteAsync_WithCustomWorkingDirectory_UsesSpecifiedDirectory() + { + // Arrange + string tempDir = Path.GetTempPath(); + var options = new ShellToolOptions + { + WorkingDirectory = tempDir, + TimeoutInMilliseconds = 30000, + MaxOutputLength = 51200 + }; + + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cd" + : "pwd"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, options); + + // Assert + Assert.Single(results); + // Normalize paths for comparison + var outputPath = results[0].StandardOutput?.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var expectedPath = tempDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.Equal(expectedPath, outputPath, ignoreCase: RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + } + + [Fact] + public async Task ExecuteAsync_WithShortTimeout_TimesOutLongRunningCommand() + { + // Arrange + var options = new ShellToolOptions + { + TimeoutInMilliseconds = 100, // Very short timeout + MaxOutputLength = 51200 + }; + + // Command that sleeps longer than timeout + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "ping -n 10 127.0.0.1" + : "sleep 10"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, options); + + // Assert + Assert.Single(results); + Assert.True(results[0].IsTimedOut); + Assert.Null(results[0].ExitCode); // No exit code when timed out + } + + [Fact] + public async Task ExecuteAsync_WithSmallMaxOutputLength_TruncatesLargeOutput() + { + // Arrange + var options = new ShellToolOptions + { + TimeoutInMilliseconds = 30000, + MaxOutputLength = 100 // Very small output limit + }; + + // Command that generates lots of output + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd /c \"for /L %i in (1,1,1000) do @echo Line %i\"" + : "for i in $(seq 1 1000); do echo Line $i; done"; + + // Act + var results = await _executor.ExecuteAsync(new[] { command }, options); + + // Assert + Assert.Single(results); + Assert.True(results[0].IsTruncated); + Assert.True(results[0].StandardOutput?.Length <= options.MaxOutputLength); + } + + [Fact] + public async Task ExecuteAsync_WithNonExistentCommand_ReturnsErrorOrNonZeroExitCode() + { + // Act + var results = await _executor.ExecuteAsync(s_nonExistentCommand, _options); + + // Assert + Assert.Single(results); + // Either returns error in stderr or has non-zero exit code + Assert.True( + results[0].ExitCode != 0 || + !string.IsNullOrEmpty(results[0].StandardError) || + !string.IsNullOrEmpty(results[0].Error)); + } + + [Fact] + public async Task ExecuteAsync_WithCustomShell_UsesSpecifiedShell() + { + // Skip on non-Windows for this specific test + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arrange + var options = new ShellToolOptions + { + Shell = "powershell.exe", + TimeoutInMilliseconds = 30000, + MaxOutputLength = 51200 + }; + + // Act + var results = await _executor.ExecuteAsync(s_powershellCommand, options); + + // Assert + Assert.Single(results); + Assert.Contains("hello", results[0].StandardOutput); + } + + [Fact] + public async Task ExecuteAsync_WithCancellationToken_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + + // Command that sleeps + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "ping -n 100 127.0.0.1" + : "sleep 100"; + + // Cancel after a short delay + cts.CancelAfter(100); + + // Act & Assert - TaskCanceledException derives from OperationCanceledException + await Assert.ThrowsAnyAsync(() => + _executor.ExecuteAsync(new[] { command }, _options, cts.Token)); + } + + [Fact] + public async Task ExecuteAsync_WithEmptyCommandList_ReturnsEmptyList() + { + // Act + var results = await _executor.ExecuteAsync(Array.Empty(), _options); + + // Assert + Assert.Empty(results); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/Microsoft.Agents.AI.Shell.Local.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/Microsoft.Agents.AI.Shell.Local.IntegrationTests.csproj new file mode 100644 index 0000000000..b685170d09 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/Microsoft.Agents.AI.Shell.Local.IntegrationTests.csproj @@ -0,0 +1,11 @@ + + + + $(NoWarn);MEAI001 + + + + + + + From 1b91910263cc899efe2e6b16db27476228c79309 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:12:35 -0800 Subject: [PATCH 02/12] Added more validation --- .../Shell/ShellTool.cs | 373 +++++++++++++++++- .../Shell/ShellToolOptions.cs | 51 +++ .../Shell/ShellToolOptionsTests.cs | 62 +++ .../Shell/ShellToolTests.cs | 333 ++++++++++++++++ 4 files changed, 808 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs index d5c81b8628..f50730f13f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -2,7 +2,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -37,6 +40,39 @@ public class ShellTool : AITool "pkexec" ]; + private static readonly string[] ShellWrapperCommands = + [ + "sh", + "bash", + "zsh", + "dash", + "ksh", + "csh", + "tcsh" + ]; + + private static readonly Regex[] DefaultDangerousPatterns = + [ + // Fork bomb: :(){ :|:& };: + new Regex(@":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + // rm -rf / variants + new Regex(@"rm\s+(-[rRfF]+\s+)*(/|/\*|\*/)", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + // Format filesystem + new Regex(@"mkfs\.", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + // Direct disk write + new Regex(@"dd\s+.*of=/dev/", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + // Overwrite disk + new Regex(@">\s*/dev/sd", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + // chmod 777 / + new Regex(@"chmod\s+(-[rR]\s+)?777\s+/", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), + ]; + + // Pattern to extract paths from commands (Unix and Windows paths) + private static readonly Regex PathPattern = new Regex( + @"(?:^|\s)(/[^\s""'|;&<>]+|[A-Za-z]:\\[^\s""'|;&<>]+|""[^""]+"")", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + /// /// Initializes a new instance of the class. /// @@ -129,6 +165,10 @@ private static ShellToolOptions ApplyOverrides(ShellToolOptions baseOptions, She AllowedCommands = baseOptions.AllowedCommands, DeniedCommands = baseOptions.DeniedCommands, BlockPrivilegeEscalation = baseOptions.BlockPrivilegeEscalation, + BlockCommandChaining = baseOptions.BlockCommandChaining, + BlockDangerousPatterns = baseOptions.BlockDangerousPatterns, + BlockedPaths = baseOptions.BlockedPaths, + AllowedPaths = baseOptions.AllowedPaths, Shell = baseOptions.Shell }; } @@ -148,7 +188,37 @@ private void ValidateCommand(string command) } } - // 2. Check allowlist (if configured) + // 2. Check default dangerous patterns (if enabled) + if (_options.BlockDangerousPatterns) + { + foreach (var pattern in DefaultDangerousPatterns) + { + if (pattern.IsMatch(command)) + { + throw new InvalidOperationException( + "Command blocked by dangerous pattern."); + } + } + } + + // 3. Check command chaining (if enabled) + if (_options.BlockCommandChaining && ContainsCommandChaining(command)) + { + throw new InvalidOperationException( + "Command chaining operators are blocked."); + } + + // 4. Check privilege escalation + if (_options.BlockPrivilegeEscalation && ContainsPrivilegeEscalation(command)) + { + throw new InvalidOperationException( + "Privilege escalation commands are blocked."); + } + + // 5. Check path access control + ValidatePathAccess(command); + + // 6. Check allowlist (if configured) if (_options.CompiledAllowedPatterns is { Count: > 0 }) { bool allowed = _options.CompiledAllowedPatterns @@ -159,29 +229,310 @@ private void ValidateCommand(string command) "Command not in allowlist."); } } + } - // 3. Check privilege escalation - if (_options.BlockPrivilegeEscalation && - ContainsPrivilegeEscalation(command)) + private static bool ContainsCommandChaining(string command) + { + var inSingleQuote = false; + var inDoubleQuote = false; + var i = 0; + + while (i < command.Length) { - throw new InvalidOperationException( - "Privilege escalation commands are blocked."); + var c = command[i]; + + // Handle escape sequences + if (c == '\\' && i + 1 < command.Length) + { + i += 2; + continue; + } + + // Handle quote state transitions + if (c == '\'' && !inDoubleQuote) + { + inSingleQuote = !inSingleQuote; + i++; + continue; + } + + if (c == '"' && !inSingleQuote) + { + inDoubleQuote = !inDoubleQuote; + i++; + continue; + } + + // Only check for operators outside quotes + if (!inSingleQuote && !inDoubleQuote) + { + // Check for semicolon + if (c == ';') + { + return true; + } + + // Check for pipe (but not ||) + if (c == '|') + { + // Check if it's || (OR operator) or just | + return true; // Both are blocked + } + + // Check for && + if (c == '&' && i + 1 < command.Length && command[i + 1] == '&') + { + return true; + } + + // Check for $() command substitution + if (c == '$' && i + 1 < command.Length && command[i + 1] == '(') + { + return true; + } + + // Check for backtick command substitution + if (c == '`') + { + return true; + } + } + + i++; } + + return false; } private static bool ContainsPrivilegeEscalation(string command) { - var trimmed = command.TrimStart(); + var tokens = TokenizeCommand(command); + + if (tokens.Count == 0) + { + return false; + } + + var firstToken = tokens[0]; + + // Normalize: extract filename from path (e.g., "/usr/bin/sudo" -> "sudo") + var executable = Path.GetFileName(firstToken); + + // Also handle Windows .exe extension (e.g., "runas.exe" -> "runas") + var executableWithoutExt = Path.GetFileNameWithoutExtension(firstToken); + + // Check if the first token is a privilege escalation command + if (PrivilegeEscalationCommands.Any(d => + string.Equals(executable, d, StringComparison.OrdinalIgnoreCase) || + string.Equals(executableWithoutExt, d, StringComparison.OrdinalIgnoreCase))) + { + return true; + } - foreach (var dangerous in PrivilegeEscalationCommands) + // Check for shell wrapper patterns (e.g., "sh -c 'sudo ...'") and recursively validate + if (IsShellWrapper(executable, executableWithoutExt) && tokens.Count >= 3) { - if (trimmed.StartsWith(dangerous + " ", StringComparison.OrdinalIgnoreCase) || - trimmed.Equals(dangerous, StringComparison.OrdinalIgnoreCase)) + // Look for -c flag followed by command string + for (var i = 1; i < tokens.Count - 1; i++) { - return true; + if (string.Equals(tokens[i], "-c", StringComparison.Ordinal)) + { + // The next token is the command string to execute + var nestedCommand = tokens[i + 1]; + if (ContainsPrivilegeEscalation(nestedCommand)) + { + return true; + } + + break; + } } } return false; } + + private static bool IsShellWrapper(string executable, string executableWithoutExt) + { + return ShellWrapperCommands.Any(s => + string.Equals(executable, s, StringComparison.OrdinalIgnoreCase) || + string.Equals(executableWithoutExt, s, StringComparison.OrdinalIgnoreCase)); + } + + private static List TokenizeCommand(string command) + { + var tokens = new List(); + var currentToken = new StringBuilder(); + var inSingleQuote = false; + var inDoubleQuote = false; + var i = 0; + + while (i < command.Length) + { + var c = command[i]; + + // Handle escape sequences (only when inside double quotes or for special characters) + // Don't treat backslash as escape if followed by alphanumeric (likely Windows path) + if (c == '\\' && i + 1 < command.Length && !inSingleQuote) + { + var nextChar = command[i + 1]; + var isEscapeSequence = inDoubleQuote || !char.IsLetterOrDigit(nextChar); + + if (isEscapeSequence) + { + currentToken.Append(nextChar); + i += 2; + continue; + } + } + + // Handle quote state transitions + if (c == '\'' && !inDoubleQuote) + { + inSingleQuote = !inSingleQuote; + i++; + continue; + } + + if (c == '"' && !inSingleQuote) + { + inDoubleQuote = !inDoubleQuote; + i++; + continue; + } + + // Handle whitespace + if (char.IsWhiteSpace(c) && !inSingleQuote && !inDoubleQuote) + { + if (currentToken.Length > 0) + { + tokens.Add(currentToken.ToString()); + currentToken.Clear(); + } + + i++; + continue; + } + + currentToken.Append(c); + i++; + } + + // Add the last token if any + if (currentToken.Length > 0) + { + tokens.Add(currentToken.ToString()); + } + + return tokens; + } + + private void ValidatePathAccess(string command) + { + var blockedPaths = _options.BlockedPaths; + var allowedPaths = _options.AllowedPaths; + + // If no path restrictions are configured, skip + if ((blockedPaths is null || blockedPaths.Count == 0) && + (allowedPaths is null || allowedPaths.Count == 0)) + { + return; + } + + // Extract paths from the command + foreach (var path in ExtractPaths(command)) + { + var normalizedPath = NormalizePath(path); + + // Check blocklist first (takes priority) + if (blockedPaths is { Count: > 0 }) + { + foreach (var blockedPath in blockedPaths) + { + var normalizedBlockedPath = NormalizePath(blockedPath); + if (IsPathWithin(normalizedPath, normalizedBlockedPath)) + { + throw new InvalidOperationException( + $"Access to path '{path}' is blocked."); + } + } + } + + // Check allowlist (if configured, all paths must be within allowed paths) + if (allowedPaths is { Count: > 0 }) + { + var isAllowed = allowedPaths.Any(allowedPath => + IsPathWithin(normalizedPath, NormalizePath(allowedPath))); + + if (!isAllowed) + { + throw new InvalidOperationException( + $"Access to path '{path}' is not allowed."); + } + } + } + } + + private static List ExtractPaths(string command) + { + var paths = new List(); + + foreach (Match match in PathPattern.Matches(command)) + { + var path = match.Groups[1].Value.Trim(); + + // Remove surrounding quotes if present + if (path.Length > 1 && path[0] == '"' && path[path.Length - 1] == '"') + { + path = path.Substring(1, path.Length - 2); + } + + // Only add actual paths (not empty or just whitespace) + if (!string.IsNullOrWhiteSpace(path)) + { + paths.Add(path); + } + } + + return paths; + } + + private static string NormalizePath(string path) + { + // Handle empty or whitespace + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + // Normalize path separators and resolve . and .. + try + { + // Use GetFullPath to resolve relative paths like /etc/../etc + var fullPath = Path.GetFullPath(path); + + // Normalize to forward slashes for consistent comparison on all platforms + return fullPath.Replace('\\', '/').TrimEnd('/').ToUpperInvariant(); + } + catch + { + // If path resolution fails, just normalize separators + return path.Replace('\\', '/').TrimEnd('/').ToUpperInvariant(); + } + } + + private static bool IsPathWithin(string path, string basePath) + { + if (string.IsNullOrEmpty(basePath)) + { + return false; + } + + // Ensure basePath ends with separator for proper prefix matching + var basePathWithSep = basePath[basePath.Length - 1] == '/' ? basePath : basePath + "/"; + + // Path is within basePath if it equals basePath or starts with basePath/ + return string.Equals(path, basePath, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(basePathWithSep, StringComparison.OrdinalIgnoreCase); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs index 217433d378..869a97106c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs @@ -91,6 +91,57 @@ public IList? DeniedCommands /// public bool BlockPrivilegeEscalation { get; set; } = true; + /// + /// Gets or sets a value indicating whether command chaining operators are blocked. + /// Default: true. + /// + /// + /// + /// When enabled, commands containing shell metacharacters for chaining are blocked. + /// This includes: ; (command separator), | (pipe), && (AND), + /// || (OR), $() (command substitution), and backticks. + /// + /// + /// Operators inside quoted strings are allowed. + /// + /// + public bool BlockCommandChaining { get; set; } = true; + + /// + /// Gets or sets a value indicating whether default dangerous patterns are blocked. + /// Default: true. + /// + /// + /// When enabled, commands matching dangerous patterns are blocked, including fork bombs, + /// rm -rf / variants, filesystem formatting commands, and direct disk writes. + /// + public bool BlockDangerousPatterns { get; set; } = true; + + /// + /// Gets or sets paths that commands are not allowed to access. + /// Takes priority over . + /// + /// + /// Paths are normalized for comparison. A command is blocked if it references + /// any path that starts with a blocked path. + /// + public IList? BlockedPaths { get; set; } + + /// + /// Gets or sets paths that commands are allowed to access. + /// If set, commands can only access these paths. + /// + /// + /// + /// When configured, all paths in the command must be within one of the allowed paths. + /// If a command references a path not in the allowed list, it will be blocked. + /// + /// + /// takes priority over this setting. + /// + /// + public IList? AllowedPaths { get; set; } + /// /// Gets or sets the shell executable to use. /// When null, auto-detects based on OS (cmd.exe on Windows, /bin/sh on Unix). diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs index 8fb0e3cffb..6f4441d129 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -23,6 +23,10 @@ public void Constructor_WithDefaults_HasExpectedValues() Assert.Null(options.AllowedCommands); Assert.Null(options.DeniedCommands); Assert.True(options.BlockPrivilegeEscalation); + Assert.True(options.BlockCommandChaining); + Assert.True(options.BlockDangerousPatterns); + Assert.Null(options.BlockedPaths); + Assert.Null(options.AllowedPaths); Assert.Null(options.Shell); } @@ -134,4 +138,62 @@ public void AllowedCommands_WhenUpdated_RecompilesPatterns() Assert.NotNull(options.CompiledAllowedPatterns); Assert.Equal(2, options.CompiledAllowedPatterns.Count); } + + [Fact] + public void BlockCommandChaining_CanBeDisabled() + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + + // Assert + Assert.False(options.BlockCommandChaining); + } + + [Fact] + public void BlockDangerousPatterns_CanBeDisabled() + { + // Arrange + var options = new ShellToolOptions + { + BlockDangerousPatterns = false + }; + + // Assert + Assert.False(options.BlockDangerousPatterns); + } + + [Fact] + public void BlockedPaths_CanBeConfigured() + { + // Arrange + var options = new ShellToolOptions + { + BlockedPaths = new List { "/etc", "/var/log" } + }; + + // Assert + Assert.NotNull(options.BlockedPaths); + Assert.Equal(2, options.BlockedPaths.Count); + Assert.Contains("/etc", options.BlockedPaths); + Assert.Contains("/var/log", options.BlockedPaths); + } + + [Fact] + public void AllowedPaths_CanBeConfigured() + { + // Arrange + var options = new ShellToolOptions + { + AllowedPaths = new List { "/tmp", "/home/user" } + }; + + // Assert + Assert.NotNull(options.AllowedPaths); + Assert.Equal(2, options.AllowedPaths.Count); + Assert.Contains("/tmp", options.AllowedPaths); + Assert.Contains("/home/user", options.AllowedPaths); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs index f59e61e10c..6b974b494a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs @@ -23,6 +23,20 @@ public class ShellToolTests private static readonly string[] s_testCommand = ["test"]; private static readonly string[] s_mixedCommands = ["safe command", "dangerous command"]; + // Command chaining test arrays + private static readonly string[] s_echoHelloEchoWorldCommand = ["echo hello; echo world"]; + private static readonly string[] s_rmRfSlashCommand = ["rm -rf /"]; + + // Path access control test arrays + private static readonly string[] s_catEtcPasswdCommand = ["cat /etc/passwd"]; + private static readonly string[] s_catTmpFileCommand = ["cat /tmp/file.txt"]; + private static readonly string[] s_catHomeUserFileCommand = ["cat /home/user/file.txt"]; + private static readonly string[] s_catTmpSecretFileCommand = ["cat /tmp/secret/file.txt"]; + private static readonly string[] s_catAnyPathFileCommand = ["cat /any/path/file.txt"]; + + // Shell wrapper test arrays + private static readonly string[] s_nestedShellWrapperSudoCommand = ["sh -c \"bash -c 'sudo command'\""]; + private readonly Mock _executorMock; public ShellToolTests() @@ -319,4 +333,323 @@ await Assert.ThrowsAsync(() => It.IsAny()), Times.Never); } + + #region Command Chaining Tests + + [Theory] + [InlineData("echo hello; echo world")] + [InlineData("cat file | grep pattern")] + [InlineData("test && echo success")] + [InlineData("test || echo failure")] + [InlineData("echo $(whoami)")] + [InlineData("echo `whoami`")] + public async Task ExecuteAsync_WithCommandChaining_ThrowsInvalidOperationException(string command) + { + // Arrange + var tool = new ShellTool(_executorMock.Object); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("CHAINING", ex.Message.ToUpperInvariant()); + } + + [Theory] + [InlineData("echo \"semicolon; in quotes\"")] + [InlineData("echo 'pipe | in single quotes'")] + [InlineData("echo \"ampersand && in quotes\"")] + [InlineData("echo \"dollar $(in quotes)\"")] + public async Task ExecuteAsync_WithOperatorsInQuotes_ReturnsResult(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockDangerousPatterns = false // Allow dangerous patterns for this test + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperators() + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false, + BlockDangerousPatterns = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_echoHelloEchoWorldCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region Dangerous Patterns Tests + + [Theory] + [InlineData(":(){ :|:& };:")] + [InlineData("rm -rf /")] + [InlineData("rm -rf /*")] + [InlineData("rm -r /")] + [InlineData("rm -f /")] + [InlineData("mkfs.ext4 /dev/sda")] + [InlineData("dd if=/dev/zero of=/dev/sda")] + [InlineData("> /dev/sda")] + [InlineData("chmod 777 /")] + [InlineData("chmod -R 777 /")] + public async Task ExecuteAsync_WithDangerousPattern_ThrowsInvalidOperationException(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining detection for these tests + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("DANGEROUS", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousCommands() + { + // Arrange + var options = new ShellToolOptions + { + BlockDangerousPatterns = false, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_rmRfSlashCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region Token-Based Privilege Escalation Tests + + [Theory] + [InlineData("/usr/bin/sudo apt install")] + [InlineData("\"/usr/bin/sudo\" command")] + [InlineData("C:\\Windows\\System32\\runas.exe /user:admin cmd")] + public async Task ExecuteAsync_WithPrivilegeEscalationInPath_ThrowsInvalidOperationException(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("PRIVILEGE ESCALATION", ex.Message.ToUpperInvariant()); + } + + [Theory] + [InlineData("/usr/bin/mysudo command")] // "mysudo" is not "sudo" + [InlineData("sudo-like command")] // Not the sudo command + public async Task ExecuteAsync_WithSimilarToPrivilegeEscalation_ReturnsResult(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region Shell Wrapper Privilege Escalation Tests + + [Theory] + [InlineData("sh -c \"sudo apt install\"")] + [InlineData("bash -c \"sudo apt update\"")] + [InlineData("/bin/sh -c \"sudo command\"")] + [InlineData("/usr/bin/bash -c \"doas command\"")] + [InlineData("zsh -c \"pkexec command\"")] + [InlineData("dash -c 'su -'")] + public async Task ExecuteAsync_WithShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationException(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining to test privilege escalation detection + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("PRIVILEGE ESCALATION", ex.Message.ToUpperInvariant()); + } + + [Theory] + [InlineData("sh -c \"echo hello\"")] + [InlineData("bash -c \"ls -la\"")] + [InlineData("/bin/sh -c \"cat file.txt\"")] + public async Task ExecuteAsync_WithShellWrapperContainingSafeCommand_ReturnsResult(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining to test shell wrappers + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithNestedShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationException() + { + // Arrange - Nested shell wrapper with privilege escalation + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_nestedShellWrapperSudoCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("PRIVILEGE ESCALATION", ex.Message.ToUpperInvariant()); + } + + #endregion + + #region Path-Based Access Control Tests + + [Fact] + public async Task ExecuteAsync_WithBlockedPath_ThrowsInvalidOperationException() + { + // Arrange + var options = new ShellToolOptions + { + BlockedPaths = new List { "/etc" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catEtcPasswdCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("BLOCKED", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithAllowedPath_ReturnsResult() + { + // Arrange + var options = new ShellToolOptions + { + AllowedPaths = new List { "/tmp" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catTmpFileCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithPathNotInAllowedList_ThrowsInvalidOperationException() + { + // Arrange + var options = new ShellToolOptions + { + AllowedPaths = new List { "/tmp" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catHomeUserFileCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("NOT ALLOWED", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithBlockedPathTakesPriorityOverAllowed_ThrowsInvalidOperationException() + { + // Arrange + var options = new ShellToolOptions + { + BlockedPaths = new List { "/tmp/secret" }, + AllowedPaths = new List { "/tmp" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catTmpSecretFileCommand); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("BLOCKED", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithNoPathRestrictions_ReturnsResult() + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catAnyPathFileCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + #endregion } From 5b024e9ecbd5970e0b26ac23717b93ff5b8c3609 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:26:10 -0800 Subject: [PATCH 03/12] Added sample --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_Step21_ShellTool.csproj | 22 +++ .../Agents/Agent_Step21_ShellTool/Program.cs | 132 ++++++++++++++++++ .../samples/GettingStarted/Agents/README.md | 1 + 4 files changed, 156 insertions(+) create mode 100644 dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj create mode 100644 dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 7a88f1b32a..53c39ea3f0 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -94,6 +94,7 @@ + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj new file mode 100644 index 0000000000..70d8a966b6 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs new file mode 100644 index 0000000000..d671312c60 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use the ShellTool with an AI agent. +// It shows security configuration options and human-in-the-loop approval for shell commands. +// +// SECURITY NOTE: The ShellTool executes real shell commands on your system. +// Always configure appropriate security restrictions before use. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// Get a temporary working directory for the shell tool +var workingDirectory = Path.Combine(Path.GetTempPath(), "shell-tool-sample"); +Directory.CreateDirectory(workingDirectory); + +Console.WriteLine($"Working directory: {workingDirectory}"); +Console.WriteLine(); + +// Create the shell tool with security options. +// This configuration restricts what commands can be executed. +var shellTool = new ShellTool( + executor: new LocalShellExecutor(), + options: new ShellToolOptions + { + // Set the working directory for command execution + WorkingDirectory = workingDirectory, + + // Restrict file system access to specific paths + AllowedPaths = [workingDirectory], + + // Block access to sensitive paths (takes priority over AllowedPaths) + // BlockedPaths = ["/etc", "/var"], + + // Only allow specific commands (regex patterns supported) + AllowedCommands = ["^ls", "^dir", "^echo", "^cat", "^type", "^mkdir", "^pwd", "^cd"], + + // Block dangerous patterns (enabled by default) + BlockDangerousPatterns = true, + + // Block command chaining operators like ; | && || (enabled by default) + BlockCommandChaining = true, + + // Block privilege escalation commands like sudo, su (enabled by default) + BlockPrivilegeEscalation = true, + + // Set execution timeout (default: 60 seconds) + TimeoutInMilliseconds = 30000, + + // Set maximum output size (default: 50KB) + MaxOutputLength = 10240 + }); + +// Convert the shell tool to an AIFunction for use with agents. +// Wrap with ApprovalRequiredAIFunction to require user approval before execution. +var shellFunction = new ApprovalRequiredAIFunction(shellTool.AsAIFunction()); + +// Create the chat client and agent with the shell tool. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .AsAIAgent( + instructions: """ + You are a helpful assistant with access to a shell tool. + You can execute shell commands to help the user with file system tasks. + Always explain what commands you're about to run before executing them. + The working directory is a temporary folder, so feel free to create files and folders there. + """, + tools: [shellFunction]); + +Console.WriteLine("Agent with Shell Tool"); +Console.WriteLine("====================="); +Console.WriteLine("This agent can execute shell commands with security restrictions."); +Console.WriteLine("Commands require user approval before execution."); +Console.WriteLine(); + +// Interactive conversation loop +AgentThread thread = await agent.GetNewThreadAsync(); + +while (true) +{ + Console.Write("You: "); + var userInput = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + var response = await agent.RunAsync(userInput, thread); + var userInputRequests = response.UserInputRequests.ToList(); + + // Handle approval requests for shell commands + while (userInputRequests.Count > 0) + { + var userInputResponses = userInputRequests + .OfType() + .Select(functionApprovalRequest => + { + Console.WriteLine(); + Console.WriteLine($"[APPROVAL REQUIRED] The agent wants to execute: {functionApprovalRequest.FunctionCall.Name}"); + + // Display the commands that will be executed + var arguments = functionApprovalRequest.FunctionCall.Arguments; + if (arguments is not null && arguments.TryGetValue("commands", out var commands) && commands is not null) + { + Console.WriteLine($"Commands: {commands}"); + } + + Console.Write("Approve? (Y/N): "); + var approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + + return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); + }) + .ToList(); + + response = await agent.RunAsync(userInputResponses, thread); + userInputRequests = response.UserInputRequests.ToList(); + } + + Console.WriteLine(); + Console.WriteLine($"Agent: {response}"); + Console.WriteLine(); +} diff --git a/dotnet/samples/GettingStarted/Agents/README.md b/dotnet/samples/GettingStarted/Agents/README.md index 032353aea1..4d78dd3641 100644 --- a/dotnet/samples/GettingStarted/Agents/README.md +++ b/dotnet/samples/GettingStarted/Agents/README.md @@ -47,6 +47,7 @@ Before you begin, ensure you have the following prerequisites: |[Deep research with an agent](./Agent_Step18_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics| |[Declarative agent](./Agent_Step19_Declarative/)|This sample demonstrates how to declaratively define an agent.| |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step20_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| +|[Using Shell Tool with security controls](./Agent_Step21_ShellTool/)|This sample demonstrates how to use the ShellTool with an AI agent, including security configuration options and human-in-the-loop approval for shell commands.| ## Running the samples from the console From 09f802247fe8a9141fdf90c6d11533d36e92e7e2 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:36:11 -0800 Subject: [PATCH 04/12] Small improvement --- .../GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs index d671312c60..085a1c2f1d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs @@ -62,17 +62,21 @@ // Wrap with ApprovalRequiredAIFunction to require user approval before execution. var shellFunction = new ApprovalRequiredAIFunction(shellTool.AsAIFunction()); +// Detect platform for shell command guidance +var operatingSystem = OperatingSystem.IsWindows() ? "Windows" : "Unix/Linux"; + // Create the chat client and agent with the shell tool. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) .AsAIAgent( - instructions: """ + instructions: $""" You are a helpful assistant with access to a shell tool. You can execute shell commands to help the user with file system tasks. Always explain what commands you're about to run before executing them. The working directory is a temporary folder, so feel free to create files and folders there. + The operating system is {operatingSystem}. """, tools: [shellFunction]); From 03404bfc97a38a98e86d318718a621953207adb6 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:18:34 -0800 Subject: [PATCH 05/12] Small updates --- .../Agent_Step21_ShellTool.csproj | 5 ++-- .../Agents/Agent_Step21_ShellTool/Program.cs | 24 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj index 70d8a966b6..fb68c88e4b 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj @@ -2,15 +2,14 @@ Exe - net10.0 + net10.0 enable enable - - + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs index 085a1c2f1d..fcd893998d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs @@ -5,20 +5,21 @@ // // SECURITY NOTE: The ShellTool executes real shell commands on your system. // Always configure appropriate security restrictions before use. +// The safest approach is to run shell commands in isolated environments (containers, VMs, sandboxes) +// with restricted permissions and network access. -using Azure.AI.OpenAI; -using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -using OpenAI.Chat; +using OpenAI; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +var modelName = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini"; -// Get a temporary working directory for the shell tool -var workingDirectory = Path.Combine(Path.GetTempPath(), "shell-tool-sample"); +// Get working directory (from environment variable or use temp folder) +var workingDirectory = Environment.GetEnvironmentVariable("SHELL_WORKING_DIR") + ?? Path.Combine(Path.GetTempPath(), "shell-tool-sample"); Directory.CreateDirectory(workingDirectory); Console.WriteLine($"Working directory: {workingDirectory}"); @@ -66,10 +67,9 @@ var operatingSystem = OperatingSystem.IsWindows() ? "Windows" : "Unix/Linux"; // Create the chat client and agent with the shell tool. -AIAgent agent = new AzureOpenAIClient( - new Uri(endpoint), - new AzureCliCredential()) - .GetChatClient(deploymentName) +AIAgent agent = new OpenAIClient(apiKey) + .GetChatClient(modelName) + .AsIChatClient() .AsAIAgent( instructions: $""" You are a helpful assistant with access to a shell tool. From 4cc16b76c240174b11d4c799b27259e9f29081d0 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:35:24 -0800 Subject: [PATCH 06/12] Fixed warnings --- .../Shell/ShellCallContent.cs | 24 +--- .../Shell/ShellResultContent.cs | 8 +- .../Shell/ShellTool.cs | 108 +++++++---------- .../Shell/ShellToolExtensions.cs | 16 +-- .../Shell/ShellToolOptions.cs | 21 ++-- .../LocalShellExecutor.cs | 3 +- .../Shell/ShellToolTests.cs | 110 +++++++----------- 7 files changed, 105 insertions(+), 185 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs index 3f20b0a733..e086bb6a0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Diagnostics; @@ -22,8 +22,8 @@ public sealed class ShellCallContent : AIContent [JsonConstructor] public ShellCallContent(string callId, IReadOnlyList commands) { - CallId = Throw.IfNull(callId); - Commands = Throw.IfNull(commands); + this.CallId = Throw.IfNull(callId); + this.Commands = Throw.IfNull(commands); } /// @@ -36,24 +36,8 @@ public ShellCallContent(string callId, IReadOnlyList commands) /// public IReadOnlyList Commands { get; } - /// - /// Gets or sets the timeout in milliseconds. - /// - /// - /// If not specified, the value will be used. - /// - public int? TimeoutInMilliseconds { get; set; } - - /// - /// Gets or sets the maximum output length in bytes. - /// - /// - /// If not specified, the value will be used. - /// - public int? MaxOutputLength { get; set; } - /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => - $"ShellCall = {CallId}, Commands = {Commands.Count}"; + $"ShellCall = {this.CallId}, Commands = {this.Commands.Count}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs index 592f93f2ad..7b311b8d35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellResultContent.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Diagnostics; @@ -22,8 +22,8 @@ public sealed class ShellResultContent : AIContent [JsonConstructor] public ShellResultContent(string callId, IReadOnlyList output) { - CallId = Throw.IfNull(callId); - Output = Throw.IfNull(output); + this.CallId = Throw.IfNull(callId); + this.Output = Throw.IfNull(output); } /// @@ -44,5 +44,5 @@ public ShellResultContent(string callId, IReadOnlyList outpu /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => - $"ShellResult = {CallId}, Outputs = {Output.Count}"; + $"ShellResult = {this.CallId}, Outputs = {this.Output.Count}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs index f50730f13f..403fcf796e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -28,10 +28,9 @@ namespace Microsoft.Agents.AI; /// public class ShellTool : AITool { - private readonly ShellToolOptions _options; private readonly ShellExecutor _executor; - private static readonly string[] PrivilegeEscalationCommands = + private static readonly string[] s_privilegeEscalationCommands = [ "sudo", "su", @@ -40,7 +39,7 @@ public class ShellTool : AITool "pkexec" ]; - private static readonly string[] ShellWrapperCommands = + private static readonly string[] s_shellWrapperCommands = [ "sh", "bash", @@ -51,7 +50,7 @@ public class ShellTool : AITool "tcsh" ]; - private static readonly Regex[] DefaultDangerousPatterns = + private static readonly Regex[] s_defaultDangerousPatterns = [ // Fork bomb: :(){ :|:& };: new Regex(@":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), @@ -67,12 +66,6 @@ public class ShellTool : AITool new Regex(@"chmod\s+(-[rR]\s+)?777\s+/", RegexOptions.Compiled, TimeSpan.FromSeconds(1)), ]; - // Pattern to extract paths from commands (Unix and Windows paths) - private static readonly Regex PathPattern = new Regex( - @"(?:^|\s)(/[^\s""'|;&<>]+|[A-Za-z]:\\[^\s""'|;&<>]+|""[^""]+"")", - RegexOptions.Compiled, - TimeSpan.FromSeconds(1)); - /// /// Initializes a new instance of the class. /// @@ -81,8 +74,8 @@ public class ShellTool : AITool /// is null. public ShellTool(ShellExecutor executor, ShellToolOptions? options = null) { - _executor = Throw.IfNull(executor); - _options = options ?? new ShellToolOptions(); + this._executor = Throw.IfNull(executor); + this.Options = options ?? new ShellToolOptions(); } /// @@ -99,7 +92,7 @@ public ShellTool(ShellExecutor executor, ShellToolOptions? options = null) /// /// Gets the configured options for this shell tool. /// - public ShellToolOptions Options => _options; + public ShellToolOptions Options { get; } /// /// Executes shell commands and returns result content. @@ -115,19 +108,16 @@ public async Task ExecuteAsync( { _ = Throw.IfNull(callContent); - // Apply call-specific overrides - var effectiveOptions = ApplyOverrides(_options, callContent); - // Validate all commands first foreach (var command in callContent.Commands) { - ValidateCommand(command); + this.ValidateCommand(command); } // Execute via the executor - var rawOutputs = await _executor.ExecuteAsync( + var rawOutputs = await this._executor.ExecuteAsync( callContent.Commands, - effectiveOptions, + this.Options, cancellationToken).ConfigureAwait(false); // Convert to content @@ -144,41 +134,16 @@ public async Task ExecuteAsync( return new ShellResultContent(callContent.CallId, outputs) { - MaxOutputLength = effectiveOptions.MaxOutputLength - }; - } - - private static ShellToolOptions ApplyOverrides(ShellToolOptions baseOptions, ShellCallContent callContent) - { - // If no overrides specified, use base options - if (callContent.TimeoutInMilliseconds is null && callContent.MaxOutputLength is null) - { - return baseOptions; - } - - // Create effective options with overrides - return new ShellToolOptions - { - WorkingDirectory = baseOptions.WorkingDirectory, - TimeoutInMilliseconds = callContent.TimeoutInMilliseconds ?? baseOptions.TimeoutInMilliseconds, - MaxOutputLength = callContent.MaxOutputLength ?? baseOptions.MaxOutputLength, - AllowedCommands = baseOptions.AllowedCommands, - DeniedCommands = baseOptions.DeniedCommands, - BlockPrivilegeEscalation = baseOptions.BlockPrivilegeEscalation, - BlockCommandChaining = baseOptions.BlockCommandChaining, - BlockDangerousPatterns = baseOptions.BlockDangerousPatterns, - BlockedPaths = baseOptions.BlockedPaths, - AllowedPaths = baseOptions.AllowedPaths, - Shell = baseOptions.Shell + MaxOutputLength = this.Options.MaxOutputLength }; } private void ValidateCommand(string command) { // 1. Check denylist first (priority over allowlist) - if (_options.CompiledDeniedPatterns is { Count: > 0 }) + if (this.Options.CompiledDeniedPatterns is { Count: > 0 }) { - foreach (var pattern in _options.CompiledDeniedPatterns) + foreach (var pattern in this.Options.CompiledDeniedPatterns) { if (pattern.IsMatch(command)) { @@ -189,9 +154,9 @@ private void ValidateCommand(string command) } // 2. Check default dangerous patterns (if enabled) - if (_options.BlockDangerousPatterns) + if (this.Options.BlockDangerousPatterns) { - foreach (var pattern in DefaultDangerousPatterns) + foreach (var pattern in s_defaultDangerousPatterns) { if (pattern.IsMatch(command)) { @@ -202,26 +167,26 @@ private void ValidateCommand(string command) } // 3. Check command chaining (if enabled) - if (_options.BlockCommandChaining && ContainsCommandChaining(command)) + if (this.Options.BlockCommandChaining && ContainsCommandChaining(command)) { throw new InvalidOperationException( "Command chaining operators are blocked."); } // 4. Check privilege escalation - if (_options.BlockPrivilegeEscalation && ContainsPrivilegeEscalation(command)) + if (this.Options.BlockPrivilegeEscalation && ContainsPrivilegeEscalation(command)) { throw new InvalidOperationException( "Privilege escalation commands are blocked."); } // 5. Check path access control - ValidatePathAccess(command); + this.ValidatePathAccess(command); // 6. Check allowlist (if configured) - if (_options.CompiledAllowedPatterns is { Count: > 0 }) + if (this.Options.CompiledAllowedPatterns is { Count: > 0 }) { - bool allowed = _options.CompiledAllowedPatterns + bool allowed = this.Options.CompiledAllowedPatterns .Any(p => p.IsMatch(command)); if (!allowed) { @@ -322,7 +287,7 @@ private static bool ContainsPrivilegeEscalation(string command) var executableWithoutExt = Path.GetFileNameWithoutExtension(firstToken); // Check if the first token is a privilege escalation command - if (PrivilegeEscalationCommands.Any(d => + if (s_privilegeEscalationCommands.Any(d => string.Equals(executable, d, StringComparison.OrdinalIgnoreCase) || string.Equals(executableWithoutExt, d, StringComparison.OrdinalIgnoreCase))) { @@ -354,7 +319,7 @@ private static bool ContainsPrivilegeEscalation(string command) private static bool IsShellWrapper(string executable, string executableWithoutExt) { - return ShellWrapperCommands.Any(s => + return s_shellWrapperCommands.Any(s => string.Equals(executable, s, StringComparison.OrdinalIgnoreCase) || string.Equals(executableWithoutExt, s, StringComparison.OrdinalIgnoreCase)); } @@ -429,8 +394,8 @@ private static List TokenizeCommand(string command) private void ValidatePathAccess(string command) { - var blockedPaths = _options.BlockedPaths; - var allowedPaths = _options.AllowedPaths; + var blockedPaths = this.Options.BlockedPaths; + var allowedPaths = this.Options.AllowedPaths; // If no path restrictions are configured, skip if ((blockedPaths is null || blockedPaths.Count == 0) && @@ -473,24 +438,31 @@ private void ValidatePathAccess(string command) } } - private static List ExtractPaths(string command) + private List ExtractPaths(string command) { var paths = new List(); + var tokens = TokenizeCommand(command); - foreach (Match match in PathPattern.Matches(command)) + // Skip command name (first token), check remaining for paths + for (var i = 1; i < tokens.Count; i++) { - var path = match.Groups[1].Value.Trim(); + var token = tokens[i]; - // Remove surrounding quotes if present - if (path.Length > 1 && path[0] == '"' && path[path.Length - 1] == '"') + // Skip flags/options + if (token.StartsWith("-", StringComparison.Ordinal)) { - path = path.Substring(1, path.Length - 2); + continue; } - // Only add actual paths (not empty or just whitespace) - if (!string.IsNullOrWhiteSpace(path)) + // Check if token looks like a path (contains separators or starts with .) + if (token.IndexOf('/') >= 0 || token.IndexOf('\\') >= 0 || + token.StartsWith(".", StringComparison.Ordinal)) { - paths.Add(path); + // Resolve relative paths against working directory + var resolved = Path.IsPathRooted(token) + ? token + : Path.Combine(this.Options.WorkingDirectory ?? Environment.CurrentDirectory, token); + paths.Add(resolved); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs index 101bbe882c..008c15cab7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs @@ -1,9 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.ComponentModel; using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -18,8 +17,6 @@ public static class ShellToolExtensions /// Converts a to an for use with agents. /// /// The shell tool to convert. - /// Optional override for timeout in milliseconds. If null, uses . - /// Optional override for max output length. If null, uses . /// An that wraps the shell tool. /// is null. /// @@ -29,10 +26,7 @@ public static class ShellToolExtensions /// the output for each command. /// /// - public static AIFunction AsAIFunction( - this ShellTool shellTool, - int? timeoutInMilliseconds = null, - int? maxOutputLength = null) + public static AIFunction AsAIFunction(this ShellTool shellTool) { _ = Throw.IfNull(shellTool); @@ -44,11 +38,7 @@ public static AIFunction AsAIFunction( { var callContent = new ShellCallContent( Guid.NewGuid().ToString(), - commands) - { - TimeoutInMilliseconds = timeoutInMilliseconds, - MaxOutputLength = maxOutputLength - }; + commands); return await shellTool.ExecuteAsync(callContent, cancellationToken).ConfigureAwait(false); }, diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs index 869a97106c..fcd2709339 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs @@ -11,11 +11,6 @@ namespace Microsoft.Agents.AI; /// public class ShellToolOptions { - private IList? _allowedCommands; - private IList? _deniedCommands; - private IReadOnlyList? _compiledAllowedPatterns; - private IReadOnlyList? _compiledDeniedPatterns; - /// /// Gets or sets the working directory for command execution. /// When null, uses the current working directory. @@ -50,11 +45,11 @@ public class ShellToolOptions /// public IList? AllowedCommands { - get => _allowedCommands; + get; set { - _allowedCommands = value; - _compiledAllowedPatterns = CompilePatterns(value); + field = value; + this.CompiledAllowedPatterns = CompilePatterns(value); } } @@ -73,11 +68,11 @@ public IList? AllowedCommands /// public IList? DeniedCommands { - get => _deniedCommands; + get; set { - _deniedCommands = value; - _compiledDeniedPatterns = CompilePatterns(value); + field = value; + this.CompiledDeniedPatterns = CompilePatterns(value); } } @@ -151,12 +146,12 @@ public IList? DeniedCommands /// /// Gets the compiled allowlist patterns for internal use. /// - internal IReadOnlyList? CompiledAllowedPatterns => _compiledAllowedPatterns; + internal IReadOnlyList? CompiledAllowedPatterns { get; private set; } /// /// Gets the compiled denylist patterns for internal use. /// - internal IReadOnlyList? CompiledDeniedPatterns => _compiledDeniedPatterns; + internal IReadOnlyList? CompiledDeniedPatterns { get; private set; } private static List? CompilePatterns(IList? patterns) { diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs index 2922f3b8be..677e1dab0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -6,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; namespace Microsoft.Agents.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs index 6b974b494a..63a0e48f46 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs @@ -20,7 +20,6 @@ public class ShellToolTests private static readonly string[] s_rmFileCommand = ["rm file.txt"]; private static readonly string[] s_sudoAptInstallCommand = ["sudo apt install"]; private static readonly string[] s_echoHelloCommand = ["echo hello"]; - private static readonly string[] s_testCommand = ["test"]; private static readonly string[] s_mixedCommands = ["safe command", "dangerous command"]; // Command chaining test arrays @@ -246,70 +245,6 @@ public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutput() Assert.Equal(0, result.Output[0].ExitCode); } - [Fact] - public async Task ExecuteAsync_WithTimeoutOverride_AppliesOverrideValue() - { - // Arrange - var baseOptions = new ShellToolOptions - { - TimeoutInMilliseconds = 60000 - }; - ShellToolOptions? capturedOptions = null; - _executorMock - .Setup(e => e.ExecuteAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ShellToolOptions, CancellationToken>((_, opts, _) => - capturedOptions = opts) - .ReturnsAsync(new List()); - - var tool = new ShellTool(_executorMock.Object, baseOptions); - var callContent = new ShellCallContent("call-1", s_testCommand) - { - TimeoutInMilliseconds = 30000 - }; - - // Act - await tool.ExecuteAsync(callContent); - - // Assert - Assert.NotNull(capturedOptions); - Assert.Equal(30000, capturedOptions.TimeoutInMilliseconds); - } - - [Fact] - public async Task ExecuteAsync_WithMaxOutputLengthOverride_AppliesOverrideValue() - { - // Arrange - var baseOptions = new ShellToolOptions - { - MaxOutputLength = 51200 - }; - ShellToolOptions? capturedOptions = null; - _executorMock - .Setup(e => e.ExecuteAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ShellToolOptions, CancellationToken>((_, opts, _) => - capturedOptions = opts) - .ReturnsAsync(new List()); - - var tool = new ShellTool(_executorMock.Object, baseOptions); - var callContent = new ShellCallContent("call-1", s_testCommand) - { - MaxOutputLength = 10240 - }; - - // Act - await tool.ExecuteAsync(callContent); - - // Assert - Assert.NotNull(capturedOptions); - Assert.Equal(10240, capturedOptions.MaxOutputLength); - } - [Fact] public async Task ExecuteAsync_WithMultipleCommands_ValidatesAllBeforeExecution() { @@ -651,5 +586,50 @@ public async Task ExecuteAsync_WithNoPathRestrictions_ReturnsResult() Assert.NotNull(result); } + [Theory] + [InlineData("cat ../../../etc/passwd")] + [InlineData("cat ./../../etc/passwd")] + [InlineData("ls ../secret")] + public async Task ExecuteAsync_WithRelativePathTraversal_ThrowsInvalidOperationException(string command) + { + // Arrange + var options = new ShellToolOptions + { + WorkingDirectory = "/tmp/safe", + AllowedPaths = new List { "/tmp/safe" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("NOT ALLOWED", ex.Message.ToUpperInvariant()); + } + + [Theory] + [InlineData("cat ./file.txt")] + [InlineData("ls ./subdir")] + [InlineData("cat subdir/file.txt")] + public async Task ExecuteAsync_WithRelativePathWithinAllowed_ReturnsResult(string command) + { + // Arrange + var options = new ShellToolOptions + { + WorkingDirectory = "/tmp/safe", + AllowedPaths = new List { "/tmp/safe" }, + BlockCommandChaining = false + }; + var tool = new ShellTool(_executorMock.Object, options); + var callContent = new ShellCallContent("call-1", new[] { command }); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + #endregion } From 8c9a721a00f6e6c2e82b368468daae8a2907eacd Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:37:40 -0800 Subject: [PATCH 07/12] Updated sample --- .../GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs index fcd893998d..8c3818746b 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs @@ -74,8 +74,6 @@ instructions: $""" You are a helpful assistant with access to a shell tool. You can execute shell commands to help the user with file system tasks. - Always explain what commands you're about to run before executing them. - The working directory is a temporary folder, so feel free to create files and folders there. The operating system is {operatingSystem}. """, tools: [shellFunction]); From fab10359339efb89e5a908e5fb04a3ae7f2f64dc Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:52:45 -0800 Subject: [PATCH 08/12] Improvements --- .../Shell/ShellTool.cs | 4 +- .../LocalShellExecutor.cs | 4 +- .../Shell/ShellToolOptionsTests.cs | 23 +-- .../Shell/ShellToolTests.cs | 177 +++++++++--------- .../LocalShellExecutorTests.cs | 51 +++-- 5 files changed, 127 insertions(+), 132 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs index 403fcf796e..30eef0ad47 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -405,7 +405,7 @@ private void ValidatePathAccess(string command) } // Extract paths from the command - foreach (var path in ExtractPaths(command)) + foreach (var path in this.ExtractPaths(command)) { var normalizedPath = NormalizePath(path); @@ -455,7 +455,7 @@ private List ExtractPaths(string command) } // Check if token looks like a path (contains separators or starts with .) - if (token.IndexOf('/') >= 0 || token.IndexOf('\\') >= 0 || + if (token.Contains('/') || token.Contains('\\') || token.StartsWith(".", StringComparison.Ordinal)) { // Resolve relative paths against working directory diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs index 677e1dab0d..e0ed91d11e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -79,7 +79,7 @@ private static async Task ExecuteSingleCommandAsync( if (stdout.Length + e.Data.Length + 1 > options.MaxOutputLength) { int remainingLength = options.MaxOutputLength - stdout.Length; - stdout.Append(e.Data.Substring(0, remainingLength)); + stdout.Append(e.Data, 0, remainingLength); stdoutTruncated = true; } else @@ -106,7 +106,7 @@ private static async Task ExecuteSingleCommandAsync( if (stderr.Length + e.Data.Length + 1 > options.MaxOutputLength) { int remainingLength = options.MaxOutputLength - stderr.Length; - stderr.Append(e.Data.Substring(0, remainingLength)); + stderr.Append(e.Data, 0, remainingLength); stderrTruncated = true; } else diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs index 6f4441d129..b4ba350baf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -1,7 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Agents.AI; +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -36,7 +33,7 @@ public void AllowedCommands_WithValidRegexPatterns_CompilesSuccessfully() // Arrange var options = new ShellToolOptions { - AllowedCommands = new List { "^git\\s", "^npm\\s", "^dotnet\\s" } + AllowedCommands = ["^git\\s", "^npm\\s", "^dotnet\\s"] }; // Assert @@ -50,7 +47,7 @@ public void AllowedCommands_WithInvalidRegex_TreatsAsLiteralString() // Arrange - "[" is an invalid regex pattern var options = new ShellToolOptions { - AllowedCommands = new List { "[invalid" } + AllowedCommands = ["[invalid"] }; // Assert - Should not throw, should treat as literal @@ -68,7 +65,7 @@ public void DeniedCommands_WithValidRegexPatterns_CompilesSuccessfully() // Arrange var options = new ShellToolOptions { - DeniedCommands = new List { @"rm\s+-rf", "chmod", "chown" } + DeniedCommands = [@"rm\s+-rf", "chmod", "chown"] }; // Assert @@ -82,7 +79,7 @@ public void CompiledPatterns_WithMixedCaseInput_MatchesCaseInsensitively() // Arrange var options = new ShellToolOptions { - AllowedCommands = new List { "^GIT" } + AllowedCommands = ["^GIT"] }; // Assert @@ -98,7 +95,7 @@ public void CompiledAllowedPatterns_WithEmptyList_ReturnsNull() // Arrange var options = new ShellToolOptions { - AllowedCommands = new List() + AllowedCommands = [] }; // Assert @@ -124,7 +121,7 @@ public void AllowedCommands_WhenUpdated_RecompilesPatterns() // Arrange var options = new ShellToolOptions { - AllowedCommands = new List { "^git" } + AllowedCommands = ["^git"] }; // Assert initial state @@ -132,7 +129,7 @@ public void AllowedCommands_WhenUpdated_RecompilesPatterns() Assert.Single(options.CompiledAllowedPatterns); // Act - Update the list - options.AllowedCommands = new List { "^npm", "^yarn" }; + options.AllowedCommands = ["^npm", "^yarn"]; // Assert - Patterns should be updated Assert.NotNull(options.CompiledAllowedPatterns); @@ -171,7 +168,7 @@ public void BlockedPaths_CanBeConfigured() // Arrange var options = new ShellToolOptions { - BlockedPaths = new List { "/etc", "/var/log" } + BlockedPaths = ["/etc", "/var/log"] }; // Assert @@ -187,7 +184,7 @@ public void AllowedPaths_CanBeConfigured() // Arrange var options = new ShellToolOptions { - AllowedPaths = new List { "/tmp", "/home/user" } + AllowedPaths = ["/tmp", "/home/user"] }; // Assert diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs index 63a0e48f46..62926f353e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs @@ -1,10 +1,9 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; using Moq; namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -40,16 +39,16 @@ public class ShellToolTests public ShellToolTests() { - _executorMock = new Mock(); - _executorMock + this._executorMock = new Mock(); + this._executorMock .Setup(e => e.ExecuteAsync( It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { + .ReturnsAsync( + [ new() { Command = "test", StandardOutput = "output", ExitCode = 0 } - }); + ]); } [Fact] @@ -63,7 +62,7 @@ public void Constructor_WithNullExecutor_ThrowsArgumentNullException() public void Name_WhenAccessed_ReturnsShell() { // Arrange - var tool = new ShellTool(_executorMock.Object); + var tool = new ShellTool(this._executorMock.Object); // Assert Assert.Equal("shell", tool.Name); @@ -73,17 +72,17 @@ public void Name_WhenAccessed_ReturnsShell() public void Description_WhenAccessed_ReturnsNonEmptyString() { // Arrange - var tool = new ShellTool(_executorMock.Object); + var tool = new ShellTool(this._executorMock.Object); // Assert Assert.False(string.IsNullOrWhiteSpace(tool.Description)); } [Fact] - public async Task ExecuteAsync_WithNullCallContent_ThrowsArgumentNullException() + public async Task ExecuteAsync_WithNullCallContent_ThrowsArgumentNullExceptionAsync() { // Arrange - var tool = new ShellTool(_executorMock.Object); + var tool = new ShellTool(this._executorMock.Object); // Act & Assert await Assert.ThrowsAsync(() => @@ -91,14 +90,14 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task ExecuteAsync_WithCommandMatchingDenylist_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithCommandMatchingDenylist_ThrowsInvalidOperationExceptionAsync() { // Arrange var options = new ShellToolOptions { - DeniedCommands = new List { @"rm\s+-rf" } + DeniedCommands = [@"rm\s+-rf"] }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_rmRfCommand); // Act & Assert @@ -108,14 +107,14 @@ public async Task ExecuteAsync_WithCommandMatchingDenylist_ThrowsInvalidOperatio } [Fact] - public async Task ExecuteAsync_WithCommandNotMatchingAllowlist_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithCommandNotMatchingAllowlist_ThrowsInvalidOperationExceptionAsync() { // Arrange var options = new ShellToolOptions { - AllowedCommands = new List { "^git\\s", "^npm\\s" } + AllowedCommands = ["^git\\s", "^npm\\s"] }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_curlCommand); // Act & Assert @@ -125,14 +124,14 @@ public async Task ExecuteAsync_WithCommandNotMatchingAllowlist_ThrowsInvalidOper } [Fact] - public async Task ExecuteAsync_WithCommandMatchingAllowlist_ReturnsResult() + public async Task ExecuteAsync_WithCommandMatchingAllowlist_ReturnsResultAsync() { // Arrange var options = new ShellToolOptions { - AllowedCommands = new List { "^git\\s" } + AllowedCommands = ["^git\\s"] }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_gitStatusCommand); // Act @@ -144,15 +143,15 @@ public async Task ExecuteAsync_WithCommandMatchingAllowlist_ReturnsResult() } [Fact] - public async Task ExecuteAsync_WithCommandMatchingBothLists_PrioritizesDenylist() + public async Task ExecuteAsync_WithCommandMatchingBothLists_PrioritizesDenylistAsync() { // Arrange - Command matches both allowlist and denylist var options = new ShellToolOptions { - AllowedCommands = new List { ".*" }, // Allow everything - DeniedCommands = new List { "rm" } // But deny rm + AllowedCommands = [".*"], // Allow everything + DeniedCommands = ["rm"] // But deny rm }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_rmFileCommand); // Act & Assert - Denylist should win @@ -169,11 +168,11 @@ public async Task ExecuteAsync_WithCommandMatchingBothLists_PrioritizesDenylist( [InlineData("runas /user:admin cmd")] [InlineData("doas command")] [InlineData("pkexec command")] - public async Task ExecuteAsync_WithPrivilegeEscalationCommand_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithPrivilegeEscalationCommand_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange - var tool = new ShellTool(_executorMock.Object); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -182,14 +181,14 @@ public async Task ExecuteAsync_WithPrivilegeEscalationCommand_ThrowsInvalidOpera } [Fact] - public async Task ExecuteAsync_WithPrivilegeEscalationDisabled_AllowsSudoCommands() + public async Task ExecuteAsync_WithPrivilegeEscalationDisabled_AllowsSudoCommandsAsync() { // Arrange var options = new ShellToolOptions { BlockPrivilegeEscalation = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_sudoAptInstallCommand); // Act @@ -203,11 +202,11 @@ public async Task ExecuteAsync_WithPrivilegeEscalationDisabled_AllowsSudoCommand [InlineData("sudoku game")] [InlineData("resume.txt")] [InlineData("dosomething")] - public async Task ExecuteAsync_WithSimilarButSafeCommands_ReturnsResult(string command) + public async Task ExecuteAsync_WithSimilarButSafeCommands_ReturnsResultAsync(string command) { // Arrange - var tool = new ShellTool(_executorMock.Object); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [command]); // Act var result = await tool.ExecuteAsync(callContent); @@ -217,21 +216,21 @@ public async Task ExecuteAsync_WithSimilarButSafeCommands_ReturnsResult(string c } [Fact] - public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutput() + public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutputAsync() { // Arrange var expectedOutput = new List { new() { Command = "echo hello", StandardOutput = "hello\n", ExitCode = 0 } }; - _executorMock + this._executorMock .Setup(e => e.ExecuteAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedOutput); - var tool = new ShellTool(_executorMock.Object); + var tool = new ShellTool(this._executorMock.Object); var callContent = new ShellCallContent("call-1", s_echoHelloCommand); // Act @@ -246,14 +245,14 @@ public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutput() } [Fact] - public async Task ExecuteAsync_WithMultipleCommands_ValidatesAllBeforeExecution() + public async Task ExecuteAsync_WithMultipleCommands_ValidatesAllBeforeExecutionAsync() { // Arrange var options = new ShellToolOptions { - DeniedCommands = new List { "dangerous" } + DeniedCommands = ["dangerous"] }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_mixedCommands); // Act & Assert - Should fail on second command before executing any @@ -261,7 +260,7 @@ await Assert.ThrowsAsync(() => tool.ExecuteAsync(callContent)); // Verify executor was never called - _executorMock.Verify( + this._executorMock.Verify( e => e.ExecuteAsync( It.IsAny>(), It.IsAny(), @@ -278,11 +277,11 @@ await Assert.ThrowsAsync(() => [InlineData("test || echo failure")] [InlineData("echo $(whoami)")] [InlineData("echo `whoami`")] - public async Task ExecuteAsync_WithCommandChaining_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithCommandChaining_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange - var tool = new ShellTool(_executorMock.Object); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -295,15 +294,15 @@ public async Task ExecuteAsync_WithCommandChaining_ThrowsInvalidOperationExcepti [InlineData("echo 'pipe | in single quotes'")] [InlineData("echo \"ampersand && in quotes\"")] [InlineData("echo \"dollar $(in quotes)\"")] - public async Task ExecuteAsync_WithOperatorsInQuotes_ReturnsResult(string command) + public async Task ExecuteAsync_WithOperatorsInQuotes_ReturnsResultAsync(string command) { // Arrange var options = new ShellToolOptions { BlockDangerousPatterns = false // Allow dangerous patterns for this test }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act var result = await tool.ExecuteAsync(callContent); @@ -313,7 +312,7 @@ public async Task ExecuteAsync_WithOperatorsInQuotes_ReturnsResult(string comman } [Fact] - public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperators() + public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperatorsAsync() { // Arrange var options = new ShellToolOptions @@ -321,7 +320,7 @@ public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperato BlockCommandChaining = false, BlockDangerousPatterns = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_echoHelloEchoWorldCommand); // Act @@ -346,15 +345,15 @@ public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperato [InlineData("> /dev/sda")] [InlineData("chmod 777 /")] [InlineData("chmod -R 777 /")] - public async Task ExecuteAsync_WithDangerousPattern_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithDangerousPattern_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false // Disable chaining detection for these tests }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -363,7 +362,7 @@ public async Task ExecuteAsync_WithDangerousPattern_ThrowsInvalidOperationExcept } [Fact] - public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousCommands() + public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousCommandsAsync() { // Arrange var options = new ShellToolOptions @@ -371,7 +370,7 @@ public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousComm BlockDangerousPatterns = false, BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_rmRfSlashCommand); // Act @@ -389,15 +388,15 @@ public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousComm [InlineData("/usr/bin/sudo apt install")] [InlineData("\"/usr/bin/sudo\" command")] [InlineData("C:\\Windows\\System32\\runas.exe /user:admin cmd")] - public async Task ExecuteAsync_WithPrivilegeEscalationInPath_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithPrivilegeEscalationInPath_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -408,15 +407,15 @@ public async Task ExecuteAsync_WithPrivilegeEscalationInPath_ThrowsInvalidOperat [Theory] [InlineData("/usr/bin/mysudo command")] // "mysudo" is not "sudo" [InlineData("sudo-like command")] // Not the sudo command - public async Task ExecuteAsync_WithSimilarToPrivilegeEscalation_ReturnsResult(string command) + public async Task ExecuteAsync_WithSimilarToPrivilegeEscalation_ReturnsResultAsync(string command) { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act var result = await tool.ExecuteAsync(callContent); @@ -436,15 +435,15 @@ public async Task ExecuteAsync_WithSimilarToPrivilegeEscalation_ReturnsResult(st [InlineData("/usr/bin/bash -c \"doas command\"")] [InlineData("zsh -c \"pkexec command\"")] [InlineData("dash -c 'su -'")] - public async Task ExecuteAsync_WithShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false // Disable chaining to test privilege escalation detection }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -456,15 +455,15 @@ public async Task ExecuteAsync_WithShellWrapperContainingPrivilegeEscalation_Thr [InlineData("sh -c \"echo hello\"")] [InlineData("bash -c \"ls -la\"")] [InlineData("/bin/sh -c \"cat file.txt\"")] - public async Task ExecuteAsync_WithShellWrapperContainingSafeCommand_ReturnsResult(string command) + public async Task ExecuteAsync_WithShellWrapperContainingSafeCommand_ReturnsResultAsync(string command) { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false // Disable chaining to test shell wrappers }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act var result = await tool.ExecuteAsync(callContent); @@ -474,14 +473,14 @@ public async Task ExecuteAsync_WithShellWrapperContainingSafeCommand_ReturnsResu } [Fact] - public async Task ExecuteAsync_WithNestedShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithNestedShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationExceptionAsync() { // Arrange - Nested shell wrapper with privilege escalation var options = new ShellToolOptions { BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_nestedShellWrapperSudoCommand); // Act & Assert @@ -495,15 +494,15 @@ public async Task ExecuteAsync_WithNestedShellWrapperContainingPrivilegeEscalati #region Path-Based Access Control Tests [Fact] - public async Task ExecuteAsync_WithBlockedPath_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithBlockedPath_ThrowsInvalidOperationExceptionAsync() { // Arrange var options = new ShellToolOptions { - BlockedPaths = new List { "/etc" }, + BlockedPaths = ["/etc"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_catEtcPasswdCommand); // Act & Assert @@ -513,15 +512,15 @@ public async Task ExecuteAsync_WithBlockedPath_ThrowsInvalidOperationException() } [Fact] - public async Task ExecuteAsync_WithAllowedPath_ReturnsResult() + public async Task ExecuteAsync_WithAllowedPath_ReturnsResultAsync() { // Arrange var options = new ShellToolOptions { - AllowedPaths = new List { "/tmp" }, + AllowedPaths = ["/tmp"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_catTmpFileCommand); // Act @@ -532,15 +531,15 @@ public async Task ExecuteAsync_WithAllowedPath_ReturnsResult() } [Fact] - public async Task ExecuteAsync_WithPathNotInAllowedList_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithPathNotInAllowedList_ThrowsInvalidOperationExceptionAsync() { // Arrange var options = new ShellToolOptions { - AllowedPaths = new List { "/tmp" }, + AllowedPaths = ["/tmp"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_catHomeUserFileCommand); // Act & Assert @@ -550,16 +549,16 @@ public async Task ExecuteAsync_WithPathNotInAllowedList_ThrowsInvalidOperationEx } [Fact] - public async Task ExecuteAsync_WithBlockedPathTakesPriorityOverAllowed_ThrowsInvalidOperationException() + public async Task ExecuteAsync_WithBlockedPathTakesPriorityOverAllowed_ThrowsInvalidOperationExceptionAsync() { // Arrange var options = new ShellToolOptions { - BlockedPaths = new List { "/tmp/secret" }, - AllowedPaths = new List { "/tmp" }, + BlockedPaths = ["/tmp/secret"], + AllowedPaths = ["/tmp"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_catTmpSecretFileCommand); // Act & Assert @@ -569,14 +568,14 @@ public async Task ExecuteAsync_WithBlockedPathTakesPriorityOverAllowed_ThrowsInv } [Fact] - public async Task ExecuteAsync_WithNoPathRestrictions_ReturnsResult() + public async Task ExecuteAsync_WithNoPathRestrictions_ReturnsResultAsync() { // Arrange var options = new ShellToolOptions { BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); + var tool = new ShellTool(this._executorMock.Object, options); var callContent = new ShellCallContent("call-1", s_catAnyPathFileCommand); // Act @@ -590,17 +589,17 @@ public async Task ExecuteAsync_WithNoPathRestrictions_ReturnsResult() [InlineData("cat ../../../etc/passwd")] [InlineData("cat ./../../etc/passwd")] [InlineData("ls ../secret")] - public async Task ExecuteAsync_WithRelativePathTraversal_ThrowsInvalidOperationException(string command) + public async Task ExecuteAsync_WithRelativePathTraversal_ThrowsInvalidOperationExceptionAsync(string command) { // Arrange var options = new ShellToolOptions { WorkingDirectory = "/tmp/safe", - AllowedPaths = new List { "/tmp/safe" }, + AllowedPaths = ["/tmp/safe"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act & Assert var ex = await Assert.ThrowsAsync(() => @@ -612,17 +611,17 @@ public async Task ExecuteAsync_WithRelativePathTraversal_ThrowsInvalidOperationE [InlineData("cat ./file.txt")] [InlineData("ls ./subdir")] [InlineData("cat subdir/file.txt")] - public async Task ExecuteAsync_WithRelativePathWithinAllowed_ReturnsResult(string command) + public async Task ExecuteAsync_WithRelativePathWithinAllowed_ReturnsResultAsync(string command) { // Arrange var options = new ShellToolOptions { WorkingDirectory = "/tmp/safe", - AllowedPaths = new List { "/tmp/safe" }, + AllowedPaths = ["/tmp/safe"], BlockCommandChaining = false }; - var tool = new ShellTool(_executorMock.Object, options); - var callContent = new ShellCallContent("call-1", new[] { command }); + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); // Act var result = await tool.ExecuteAsync(callContent); diff --git a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs index c8438ceeb6..45d40f81bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs @@ -1,11 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.IO; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; namespace Microsoft.Agents.AI.Shell.Local.IntegrationTests; @@ -22,8 +21,8 @@ public class LocalShellExecutorTests public LocalShellExecutorTests() { - _executor = new LocalShellExecutor(); - _options = new ShellToolOptions + this._executor = new LocalShellExecutor(); + this._options = new ShellToolOptions { TimeoutInMilliseconds = 30000, MaxOutputLength = 51200 @@ -31,7 +30,7 @@ public LocalShellExecutorTests() } [Fact] - public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutput() + public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutputAsync() { // Arrange string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -39,7 +38,7 @@ public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutput() : "echo hello"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, _options); + var results = await this._executor.ExecuteAsync([command], this._options); // Assert Assert.Single(results); @@ -50,7 +49,7 @@ public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutput() } [Fact] - public async Task ExecuteAsync_WithNonZeroExitCode_CapturesExitCode() + public async Task ExecuteAsync_WithNonZeroExitCode_CapturesExitCodeAsync() { // Arrange string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -58,7 +57,7 @@ public async Task ExecuteAsync_WithNonZeroExitCode_CapturesExitCode() : "exit 42"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, _options); + var results = await this._executor.ExecuteAsync([command], this._options); // Assert Assert.Single(results); @@ -66,7 +65,7 @@ public async Task ExecuteAsync_WithNonZeroExitCode_CapturesExitCode() } [Fact] - public async Task ExecuteAsync_WithStderrOutput_CapturesStandardError() + public async Task ExecuteAsync_WithStderrOutput_CapturesStandardErrorAsync() { // Arrange string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -74,7 +73,7 @@ public async Task ExecuteAsync_WithStderrOutput_CapturesStandardError() : "echo error message >&2"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, _options); + var results = await this._executor.ExecuteAsync([command], this._options); // Assert Assert.Single(results); @@ -82,7 +81,7 @@ public async Task ExecuteAsync_WithStderrOutput_CapturesStandardError() } [Fact] - public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequence() + public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequenceAsync() { // Arrange string[] commands = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -90,7 +89,7 @@ public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequence() : ["echo first", "echo second", "echo third"]; // Act - var results = await _executor.ExecuteAsync(commands, _options); + var results = await this._executor.ExecuteAsync(commands, this._options); // Assert Assert.Equal(3, results.Count); @@ -100,7 +99,7 @@ public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequence() } [Fact] - public async Task ExecuteAsync_WithCustomWorkingDirectory_UsesSpecifiedDirectory() + public async Task ExecuteAsync_WithCustomWorkingDirectory_UsesSpecifiedDirectoryAsync() { // Arrange string tempDir = Path.GetTempPath(); @@ -116,7 +115,7 @@ public async Task ExecuteAsync_WithCustomWorkingDirectory_UsesSpecifiedDirectory : "pwd"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, options); + var results = await this._executor.ExecuteAsync([command], options); // Assert Assert.Single(results); @@ -127,7 +126,7 @@ public async Task ExecuteAsync_WithCustomWorkingDirectory_UsesSpecifiedDirectory } [Fact] - public async Task ExecuteAsync_WithShortTimeout_TimesOutLongRunningCommand() + public async Task ExecuteAsync_WithShortTimeout_TimesOutLongRunningCommandAsync() { // Arrange var options = new ShellToolOptions @@ -142,7 +141,7 @@ public async Task ExecuteAsync_WithShortTimeout_TimesOutLongRunningCommand() : "sleep 10"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, options); + var results = await this._executor.ExecuteAsync([command], options); // Assert Assert.Single(results); @@ -151,7 +150,7 @@ public async Task ExecuteAsync_WithShortTimeout_TimesOutLongRunningCommand() } [Fact] - public async Task ExecuteAsync_WithSmallMaxOutputLength_TruncatesLargeOutput() + public async Task ExecuteAsync_WithSmallMaxOutputLength_TruncatesLargeOutputAsync() { // Arrange var options = new ShellToolOptions @@ -166,7 +165,7 @@ public async Task ExecuteAsync_WithSmallMaxOutputLength_TruncatesLargeOutput() : "for i in $(seq 1 1000); do echo Line $i; done"; // Act - var results = await _executor.ExecuteAsync(new[] { command }, options); + var results = await this._executor.ExecuteAsync([command], options); // Assert Assert.Single(results); @@ -175,10 +174,10 @@ public async Task ExecuteAsync_WithSmallMaxOutputLength_TruncatesLargeOutput() } [Fact] - public async Task ExecuteAsync_WithNonExistentCommand_ReturnsErrorOrNonZeroExitCode() + public async Task ExecuteAsync_WithNonExistentCommand_ReturnsErrorOrNonZeroExitCodeAsync() { // Act - var results = await _executor.ExecuteAsync(s_nonExistentCommand, _options); + var results = await this._executor.ExecuteAsync(s_nonExistentCommand, this._options); // Assert Assert.Single(results); @@ -190,7 +189,7 @@ public async Task ExecuteAsync_WithNonExistentCommand_ReturnsErrorOrNonZeroExitC } [Fact] - public async Task ExecuteAsync_WithCustomShell_UsesSpecifiedShell() + public async Task ExecuteAsync_WithCustomShell_UsesSpecifiedShellAsync() { // Skip on non-Windows for this specific test if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -207,7 +206,7 @@ public async Task ExecuteAsync_WithCustomShell_UsesSpecifiedShell() }; // Act - var results = await _executor.ExecuteAsync(s_powershellCommand, options); + var results = await this._executor.ExecuteAsync(s_powershellCommand, options); // Assert Assert.Single(results); @@ -215,7 +214,7 @@ public async Task ExecuteAsync_WithCustomShell_UsesSpecifiedShell() } [Fact] - public async Task ExecuteAsync_WithCancellationToken_ThrowsOperationCanceledException() + public async Task ExecuteAsync_WithCancellationToken_ThrowsOperationCanceledExceptionAsync() { // Arrange using var cts = new CancellationTokenSource(); @@ -230,14 +229,14 @@ public async Task ExecuteAsync_WithCancellationToken_ThrowsOperationCanceledExce // Act & Assert - TaskCanceledException derives from OperationCanceledException await Assert.ThrowsAnyAsync(() => - _executor.ExecuteAsync(new[] { command }, _options, cts.Token)); + this._executor.ExecuteAsync([command], this._options, cts.Token)); } [Fact] - public async Task ExecuteAsync_WithEmptyCommandList_ReturnsEmptyList() + public async Task ExecuteAsync_WithEmptyCommandList_ReturnsEmptyListAsync() { // Act - var results = await _executor.ExecuteAsync(Array.Empty(), _options); + var results = await this._executor.ExecuteAsync([], this._options); // Assert Assert.Empty(results); From bf535bb2f4e7e963a3606fc6e918b9274ffec428 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:07:57 -0800 Subject: [PATCH 09/12] Small updates --- .../Shell/ShellExecutorOutput.cs | 8 ++------ .../Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs index 702c713e87..4b8df40192 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs @@ -1,14 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI; /// -/// Raw output from shell executor (simple data class). +/// Raw output from shell executor. /// -/// -/// This class is used internally by implementations -/// to return raw data. converts these to . -/// public sealed class ShellExecutorOutput { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs index e0ed91d11e..4628342d98 100644 --- a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -172,7 +172,7 @@ private static async Task ExecuteSingleCommandAsync( } } - private static (string shell, string args) GetShellAndArgs( + private static (string Shell, string Args) GetShellAndArgs( string command, string? shellOverride) { if (!string.IsNullOrEmpty(shellOverride)) From c662f4de93e36cfd52170169ab96d376ada6681b Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:14:16 -0800 Subject: [PATCH 10/12] Small improvements --- .../Shell/ShellTool.cs | 46 +++++++- .../Shell/ShellToolOptions.cs | 64 +--------- .../Shell/ShellToolOptionsTests.cs | 111 +----------------- 3 files changed, 45 insertions(+), 176 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs index 30eef0ad47..368176ffe5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -29,6 +29,8 @@ namespace Microsoft.Agents.AI; public class ShellTool : AITool { private readonly ShellExecutor _executor; + private readonly IReadOnlyList? _compiledAllowedPatterns; + private readonly IReadOnlyList? _compiledDeniedPatterns; private static readonly string[] s_privilegeEscalationCommands = [ @@ -76,6 +78,10 @@ public ShellTool(ShellExecutor executor, ShellToolOptions? options = null) { this._executor = Throw.IfNull(executor); this.Options = options ?? new ShellToolOptions(); + + // Compile patterns once at construction time + this._compiledAllowedPatterns = CompilePatterns(this.Options.AllowedCommands); + this._compiledDeniedPatterns = CompilePatterns(this.Options.DeniedCommands); } /// @@ -141,9 +147,9 @@ public async Task ExecuteAsync( private void ValidateCommand(string command) { // 1. Check denylist first (priority over allowlist) - if (this.Options.CompiledDeniedPatterns is { Count: > 0 }) + if (this._compiledDeniedPatterns is { Count: > 0 }) { - foreach (var pattern in this.Options.CompiledDeniedPatterns) + foreach (var pattern in this._compiledDeniedPatterns) { if (pattern.IsMatch(command)) { @@ -184,9 +190,9 @@ private void ValidateCommand(string command) this.ValidatePathAccess(command); // 6. Check allowlist (if configured) - if (this.Options.CompiledAllowedPatterns is { Count: > 0 }) + if (this._compiledAllowedPatterns is { Count: > 0 }) { - bool allowed = this.Options.CompiledAllowedPatterns + bool allowed = this._compiledAllowedPatterns .Any(p => p.IsMatch(command)); if (!allowed) { @@ -507,4 +513,36 @@ private static bool IsPathWithin(string path, string basePath) return string.Equals(path, basePath, StringComparison.OrdinalIgnoreCase) || path.StartsWith(basePathWithSep, StringComparison.OrdinalIgnoreCase); } + + private static List? CompilePatterns(IList? patterns) + { + if (patterns is null || patterns.Count == 0) + { + return null; + } + + var compiled = new List(patterns.Count); + foreach (var pattern in patterns) + { + // Try-catch is used here because there is no way to validate a regex pattern + // without attempting to compile it. + try + { + compiled.Add(new Regex( + pattern, + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1))); + } + catch (ArgumentException) + { + // Invalid regex - treat as literal string match + compiled.Add(new Regex( + Regex.Escape(pattern), + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1))); + } + } + + return compiled; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs index fcd2709339..9090d474a0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; -using System.Text.RegularExpressions; namespace Microsoft.Agents.AI; @@ -43,15 +41,7 @@ public class ShellToolOptions /// Invalid regex patterns are automatically treated as literal strings. /// /// - public IList? AllowedCommands - { - get; - set - { - field = value; - this.CompiledAllowedPatterns = CompilePatterns(value); - } - } + public IList? AllowedCommands { get; set; } /// /// Gets or sets the denylist of blocked command patterns. @@ -66,15 +56,7 @@ public IList? AllowedCommands /// Invalid regex patterns are automatically treated as literal strings. /// /// - public IList? DeniedCommands - { - get; - set - { - field = value; - this.CompiledDeniedPatterns = CompilePatterns(value); - } - } + public IList? DeniedCommands { get; set; } /// /// Gets or sets a value indicating whether privilege escalation commands are blocked. @@ -142,46 +124,4 @@ public IList? DeniedCommands /// When null, auto-detects based on OS (cmd.exe on Windows, /bin/sh on Unix). /// public string? Shell { get; set; } - - /// - /// Gets the compiled allowlist patterns for internal use. - /// - internal IReadOnlyList? CompiledAllowedPatterns { get; private set; } - - /// - /// Gets the compiled denylist patterns for internal use. - /// - internal IReadOnlyList? CompiledDeniedPatterns { get; private set; } - - private static List? CompilePatterns(IList? patterns) - { - if (patterns is null || patterns.Count == 0) - { - return null; - } - - var compiled = new List(patterns.Count); - foreach (var pattern in patterns) - { - // Try-catch is used here because there is no way to validate a regex pattern - // without attempting to compile it. - try - { - compiled.Add(new Regex( - pattern, - RegexOptions.Compiled | RegexOptions.IgnoreCase, - TimeSpan.FromSeconds(1))); - } - catch (ArgumentException) - { - // Treat as literal string match - compiled.Add(new Regex( - Regex.Escape(pattern), - RegexOptions.Compiled | RegexOptions.IgnoreCase, - TimeSpan.FromSeconds(1))); - } - } - - return compiled; - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs index b4ba350baf..2f523009d4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -27,115 +27,6 @@ public void Constructor_WithDefaults_HasExpectedValues() Assert.Null(options.Shell); } - [Fact] - public void AllowedCommands_WithValidRegexPatterns_CompilesSuccessfully() - { - // Arrange - var options = new ShellToolOptions - { - AllowedCommands = ["^git\\s", "^npm\\s", "^dotnet\\s"] - }; - - // Assert - Assert.NotNull(options.CompiledAllowedPatterns); - Assert.Equal(3, options.CompiledAllowedPatterns.Count); - } - - [Fact] - public void AllowedCommands_WithInvalidRegex_TreatsAsLiteralString() - { - // Arrange - "[" is an invalid regex pattern - var options = new ShellToolOptions - { - AllowedCommands = ["[invalid"] - }; - - // Assert - Should not throw, should treat as literal - Assert.NotNull(options.CompiledAllowedPatterns); - Assert.Single(options.CompiledAllowedPatterns); - - // The literal "[invalid" should be escaped and match exactly - Assert.Matches(options.CompiledAllowedPatterns[0], "[invalid"); - Assert.DoesNotMatch(options.CompiledAllowedPatterns[0], "invalid"); - } - - [Fact] - public void DeniedCommands_WithValidRegexPatterns_CompilesSuccessfully() - { - // Arrange - var options = new ShellToolOptions - { - DeniedCommands = [@"rm\s+-rf", "chmod", "chown"] - }; - - // Assert - Assert.NotNull(options.CompiledDeniedPatterns); - Assert.Equal(3, options.CompiledDeniedPatterns.Count); - } - - [Fact] - public void CompiledPatterns_WithMixedCaseInput_MatchesCaseInsensitively() - { - // Arrange - var options = new ShellToolOptions - { - AllowedCommands = ["^GIT"] - }; - - // Assert - Assert.NotNull(options.CompiledAllowedPatterns); - Assert.Matches(options.CompiledAllowedPatterns[0], "git status"); - Assert.Matches(options.CompiledAllowedPatterns[0], "GIT status"); - Assert.Matches(options.CompiledAllowedPatterns[0], "Git status"); - } - - [Fact] - public void CompiledAllowedPatterns_WithEmptyList_ReturnsNull() - { - // Arrange - var options = new ShellToolOptions - { - AllowedCommands = [] - }; - - // Assert - Assert.Null(options.CompiledAllowedPatterns); - } - - [Fact] - public void CompiledAllowedPatterns_WithNullList_ReturnsNull() - { - // Arrange - var options = new ShellToolOptions - { - AllowedCommands = null - }; - - // Assert - Assert.Null(options.CompiledAllowedPatterns); - } - - [Fact] - public void AllowedCommands_WhenUpdated_RecompilesPatterns() - { - // Arrange - var options = new ShellToolOptions - { - AllowedCommands = ["^git"] - }; - - // Assert initial state - Assert.NotNull(options.CompiledAllowedPatterns); - Assert.Single(options.CompiledAllowedPatterns); - - // Act - Update the list - options.AllowedCommands = ["^npm", "^yarn"]; - - // Assert - Patterns should be updated - Assert.NotNull(options.CompiledAllowedPatterns); - Assert.Equal(2, options.CompiledAllowedPatterns.Count); - } - [Fact] public void BlockCommandChaining_CanBeDisabled() { From d4d96fc0f739068794fff5a4fd46f0d10f6c0730 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:28:01 -0800 Subject: [PATCH 11/12] Fixed formatting --- dotnet/agent-framework-dotnet.slnx | 2 +- .../Shell/ShellCommandOutput.cs | 2 +- .../src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs | 2 +- .../Shell/ShellToolOptionsTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 53c39ea3f0..07c4794fea 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -425,6 +425,7 @@ + @@ -451,6 +452,5 @@ - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs index f13703a475..fad61f8c53 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCommandOutput.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs index d9342b54a3..07f8e3dd61 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading; diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs index 2f523009d4..3fcf58aaae 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Abstractions.UnitTests; From bff6cb7b07a94630919223ccf81e37fbd1339d14 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:41:47 -0800 Subject: [PATCH 12/12] Resolved comments and fixed tests --- .../Agents/Agent_Step21_ShellTool/README.md | 91 +++++++++++++++++++ .../Shell/ShellTool.cs | 7 +- .../LocalShellExecutor.cs | 2 +- .../LocalShellExecutorTests.cs | 12 +-- 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/README.md diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/README.md new file mode 100644 index 0000000000..15d5422eca --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/README.md @@ -0,0 +1,91 @@ +# Security Warning + +**This sample executes real shell commands on your system.** Before running: + +1. **Review the code** to understand what commands may be executed +2. **Run in an isolated environment** (container, VM, or sandbox) when possible +3. **Configure strict security options** to limit what the agent can do +4. **Always use human-in-the-loop approval** for shell command execution +5. **Never run with elevated privileges** (root/administrator) + +The ShellTool includes security controls, but defense-in-depth is essential when executing arbitrary commands. + +--- + +## What this sample demonstrates + +This sample demonstrates how to use the ShellTool with an AI agent to execute shell commands with security controls and human-in-the-loop approval. + +Key features: + +- Configuring ShellTool security options (allowlist, denylist, path restrictions) +- Blocking dangerous patterns, command chaining, and privilege escalation +- Using ApprovalRequiredAIFunction for human-in-the-loop command approval +- Cross-platform support (Windows and Unix/Linux) + +## Environment Variables + +Set the following environment variables on Windows: + +```powershell +# Required: Your OpenAI API key +$env:OPENAI_API_KEY="sk-..." + +# Optional: Model to use (defaults to gpt-4o-mini) +$env:OPENAI_MODEL="gpt-4o-mini" + +# Optional: Working directory for shell commands (defaults to temp folder) +$env:SHELL_WORKING_DIR="C:\path\to\working\directory" +``` + +Or on Unix/Linux: + +```bash +export OPENAI_API_KEY="sk-..." +export OPENAI_MODEL="gpt-4o-mini" +export SHELL_WORKING_DIR="/path/to/working/directory" +``` + +## Running in Docker (Recommended for Safety) + +For safer testing, run the sample in a Docker container: + +```bash +# Build the container +docker build -t shell-tool-sample . + +# Run interactively +docker run -it --rm -e OPENAI_API_KEY="sk-..." shell-tool-sample +``` + +## Security Configuration + +The sample demonstrates several security options: + +| Option | Default | Description | +|--------|---------|-------------| +| `AllowedCommands` | null | Regex patterns for allowed commands | +| `DeniedCommands` | null | Regex patterns for blocked commands | +| `AllowedPaths` | null | Paths commands can access | +| `BlockedPaths` | null | Paths commands cannot access | +| `BlockDangerousPatterns` | true | Block fork bombs, rm -rf /, etc. | +| `BlockCommandChaining` | true | Block ; \| && \|\| $() operators | +| `BlockPrivilegeEscalation` | true | Block sudo, su, runas, etc. | +| `TimeoutInMilliseconds` | 60000 | Command execution timeout | +| `MaxOutputLength` | 51200 | Maximum output size in bytes | + +## Example Interaction + +``` +You: Create a folder called test and list its contents + +[APPROVAL REQUIRED] The agent wants to execute: shell +Commands: ["mkdir test"] +Approve? (Y/N): Y + +[APPROVAL REQUIRED] The agent wants to execute: shell +Commands: ["ls test"] +Approve? (Y/N): Y + +Agent: I created the "test" folder and listed its contents. The folder is currently empty. +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs index 368176ffe5..57b604c511 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -286,11 +286,14 @@ private static bool ContainsPrivilegeEscalation(string command) var firstToken = tokens[0]; + // Normalize path separators for cross-platform compatibility + var normalizedToken = firstToken.Replace('\\', '/'); + // Normalize: extract filename from path (e.g., "/usr/bin/sudo" -> "sudo") - var executable = Path.GetFileName(firstToken); + var executable = Path.GetFileName(normalizedToken); // Also handle Windows .exe extension (e.g., "runas.exe" -> "runas") - var executableWithoutExt = Path.GetFileNameWithoutExtension(firstToken); + var executableWithoutExt = Path.GetFileNameWithoutExtension(normalizedToken); // Check if the first token is a privilege escalation command if (s_privilegeEscalationCommands.Any(d => diff --git a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs index 4628342d98..651f2ea3d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI; /// The shell can be overridden using . /// /// -public class LocalShellExecutor : ShellExecutor +public sealed class LocalShellExecutor : ShellExecutor { /// public override async Task> ExecuteAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs index 45d40f81bb..86d4d103b7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs @@ -33,16 +33,14 @@ public LocalShellExecutorTests() public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutputAsync() { // Arrange - string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "echo hello" - : "echo hello"; + const string Command = "echo hello"; // Act - var results = await this._executor.ExecuteAsync([command], this._options); + var results = await this._executor.ExecuteAsync([Command], this._options); // Assert Assert.Single(results); - Assert.Equal(command, results[0].Command); + Assert.Equal(Command, results[0].Command); Assert.Equal(0, results[0].ExitCode); Assert.Contains("hello", results[0].StandardOutput); Assert.False(results[0].IsTimedOut); @@ -84,9 +82,7 @@ public async Task ExecuteAsync_WithStderrOutput_CapturesStandardErrorAsync() public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequenceAsync() { // Arrange - string[] commands = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? ["echo first", "echo second", "echo third"] - : ["echo first", "echo second", "echo third"]; + string[] commands = ["echo first", "echo second", "echo third"]; // Act var results = await this._executor.ExecuteAsync(commands, this._options);