diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8b1b00fd2b..8ae2aa2628 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -94,6 +94,7 @@ + @@ -411,6 +412,7 @@ + @@ -429,6 +431,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..fb68c88e4b --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Agent_Step21_ShellTool.csproj @@ -0,0 +1,21 @@ + + + + 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..8c3818746b --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ShellTool/Program.cs @@ -0,0 +1,134 @@ +// 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. +// The safest approach is to run shell commands in isolated environments (containers, VMs, sandboxes) +// with restricted permissions and network access. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +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 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}"); +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()); + +// 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 OpenAIClient(apiKey) + .GetChatClient(modelName) + .AsIChatClient() + .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. + The operating system is {operatingSystem}. + """, + 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/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/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 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..e086bb6a0d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellCallContent.cs @@ -0,0 +1,43 @@ +// 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) + { + this.CallId = Throw.IfNull(callId); + this.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 a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => + $"ShellCall = {this.CallId}, Commands = {this.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..fad61f8c53 --- /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..07f8e3dd61 --- /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..4b8df40192 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellExecutorOutput.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Raw output from shell executor. +/// +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..7b311b8d35 --- /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) + { + this.CallId = Throw.IfNull(callId); + this.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 = {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 new file mode 100644 index 0000000000..57b604c511 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellTool.cs @@ -0,0 +1,551 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; +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 ShellExecutor _executor; + private readonly IReadOnlyList? _compiledAllowedPatterns; + private readonly IReadOnlyList? _compiledDeniedPatterns; + + private static readonly string[] s_privilegeEscalationCommands = + [ + "sudo", + "su", + "runas", + "doas", + "pkexec" + ]; + + private static readonly string[] s_shellWrapperCommands = + [ + "sh", + "bash", + "zsh", + "dash", + "ksh", + "csh", + "tcsh" + ]; + + private static readonly Regex[] s_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)), + ]; + + /// + /// 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) + { + 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); + } + + /// + /// 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 { get; } + + /// + /// 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); + + // Validate all commands first + foreach (var command in callContent.Commands) + { + this.ValidateCommand(command); + } + + // Execute via the executor + var rawOutputs = await this._executor.ExecuteAsync( + callContent.Commands, + this.Options, + 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 = this.Options.MaxOutputLength + }; + } + + private void ValidateCommand(string command) + { + // 1. Check denylist first (priority over allowlist) + if (this._compiledDeniedPatterns is { Count: > 0 }) + { + foreach (var pattern in this._compiledDeniedPatterns) + { + if (pattern.IsMatch(command)) + { + throw new InvalidOperationException( + "Command blocked by denylist pattern."); + } + } + } + + // 2. Check default dangerous patterns (if enabled) + if (this.Options.BlockDangerousPatterns) + { + foreach (var pattern in s_defaultDangerousPatterns) + { + if (pattern.IsMatch(command)) + { + throw new InvalidOperationException( + "Command blocked by dangerous pattern."); + } + } + } + + // 3. Check command chaining (if enabled) + if (this.Options.BlockCommandChaining && ContainsCommandChaining(command)) + { + throw new InvalidOperationException( + "Command chaining operators are blocked."); + } + + // 4. Check privilege escalation + if (this.Options.BlockPrivilegeEscalation && ContainsPrivilegeEscalation(command)) + { + throw new InvalidOperationException( + "Privilege escalation commands are blocked."); + } + + // 5. Check path access control + this.ValidatePathAccess(command); + + // 6. Check allowlist (if configured) + if (this._compiledAllowedPatterns is { Count: > 0 }) + { + bool allowed = this._compiledAllowedPatterns + .Any(p => p.IsMatch(command)); + if (!allowed) + { + throw new InvalidOperationException( + "Command not in allowlist."); + } + } + } + + private static bool ContainsCommandChaining(string command) + { + var inSingleQuote = false; + var inDoubleQuote = false; + var i = 0; + + while (i < command.Length) + { + 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 tokens = TokenizeCommand(command); + + if (tokens.Count == 0) + { + return false; + } + + 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(normalizedToken); + + // Also handle Windows .exe extension (e.g., "runas.exe" -> "runas") + var executableWithoutExt = Path.GetFileNameWithoutExtension(normalizedToken); + + // Check if the first token is a privilege escalation command + if (s_privilegeEscalationCommands.Any(d => + string.Equals(executable, d, StringComparison.OrdinalIgnoreCase) || + string.Equals(executableWithoutExt, d, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // Check for shell wrapper patterns (e.g., "sh -c 'sudo ...'") and recursively validate + if (IsShellWrapper(executable, executableWithoutExt) && tokens.Count >= 3) + { + // Look for -c flag followed by command string + for (var i = 1; i < tokens.Count - 1; i++) + { + 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 s_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 = this.Options.BlockedPaths; + var allowedPaths = this.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 this.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 List ExtractPaths(string command) + { + var paths = new List(); + var tokens = TokenizeCommand(command); + + // Skip command name (first token), check remaining for paths + for (var i = 1; i < tokens.Count; i++) + { + var token = tokens[i]; + + // Skip flags/options + if (token.StartsWith("-", StringComparison.Ordinal)) + { + continue; + } + + // Check if token looks like a path (contains separators or starts with .) + if (token.Contains('/') || token.Contains('\\') || + token.StartsWith(".", StringComparison.Ordinal)) + { + // Resolve relative paths against working directory + var resolved = Path.IsPathRooted(token) + ? token + : Path.Combine(this.Options.WorkingDirectory ?? Environment.CurrentDirectory, token); + paths.Add(resolved); + } + } + + 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); + } + + 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/ShellToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs new file mode 100644 index 0000000000..008c15cab7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolExtensions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Threading; +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. + /// 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) + { + _ = 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); + + 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..9090d474a0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Shell/ShellToolOptions.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI; + +/// +/// Options for configuring shell tool behavior and security. +/// +public class ShellToolOptions +{ + /// + /// 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; set; } + + /// + /// 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; set; } + + /// + /// 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 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). + /// + public string? Shell { get; set; } +} 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..651f2ea3d5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Shell.Local/LocalShellExecutor.cs @@ -0,0 +1,243 @@ +// 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; + +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 sealed 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, 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, 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..3fcf58aaae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolOptionsTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +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.True(options.BlockCommandChaining); + Assert.True(options.BlockDangerousPatterns); + Assert.Null(options.BlockedPaths); + Assert.Null(options.AllowedPaths); + Assert.Null(options.Shell); + } + + [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 = ["/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 = ["/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 new file mode 100644 index 0000000000..62926f353e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Shell/ShellToolTests.cs @@ -0,0 +1,634 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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_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() + { + this._executorMock = new Mock(); + this._executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + [ + 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(this._executorMock.Object); + + // Assert + Assert.Equal("shell", tool.Name); + } + + [Fact] + public void Description_WhenAccessed_ReturnsNonEmptyString() + { + // Arrange + var tool = new ShellTool(this._executorMock.Object); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(tool.Description)); + } + + [Fact] + public async Task ExecuteAsync_WithNullCallContent_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var tool = new ShellTool(this._executorMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + tool.ExecuteAsync(null!)); + } + + [Fact] + public async Task ExecuteAsync_WithCommandMatchingDenylist_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var options = new ShellToolOptions + { + DeniedCommands = [@"rm\s+-rf"] + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = ["^git\\s", "^npm\\s"] + }; + var tool = new ShellTool(this._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_ReturnsResultAsync() + { + // Arrange + var options = new ShellToolOptions + { + AllowedCommands = ["^git\\s"] + }; + var tool = new ShellTool(this._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_PrioritizesDenylistAsync() + { + // Arrange - Command matches both allowlist and denylist + var options = new ShellToolOptions + { + AllowedCommands = [".*"], // Allow everything + DeniedCommands = ["rm"] // But deny rm + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [command]); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("PRIVILEGE ESCALATION", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithPrivilegeEscalationDisabled_AllowsSudoCommandsAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockPrivilegeEscalation = false + }; + var tool = new ShellTool(this._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_ReturnsResultAsync(string command) + { + // Arrange + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [command]); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithValidCommand_ReturnsCorrectOutputAsync() + { + // Arrange + var expectedOutput = new List + { + new() { Command = "echo hello", StandardOutput = "hello\n", ExitCode = 0 } + }; + this._executorMock + .Setup(e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedOutput); + + var tool = new ShellTool(this._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_WithMultipleCommands_ValidatesAllBeforeExecutionAsync() + { + // Arrange + var options = new ShellToolOptions + { + DeniedCommands = ["dangerous"] + }; + 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 + await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + + // Verify executor was never called + this._executorMock.Verify( + e => e.ExecuteAsync( + It.IsAny>(), + It.IsAny(), + 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_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var tool = new ShellTool(this._executorMock.Object); + var callContent = new ShellCallContent("call-1", [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_ReturnsResultAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockDangerousPatterns = false // Allow dangerous patterns for this test + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithCommandChainingDisabled_AllowsChainingOperatorsAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false, + BlockDangerousPatterns = false + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining detection for these tests + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + tool.ExecuteAsync(callContent)); + Assert.Contains("DANGEROUS", ex.Message.ToUpperInvariant()); + } + + [Fact] + public async Task ExecuteAsync_WithDangerousPatternsDisabled_AllowsDangerousCommandsAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockDangerousPatterns = false, + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [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_ReturnsResultAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [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_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining to test privilege escalation detection + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [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_ReturnsResultAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false // Disable chaining to test shell wrappers + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task ExecuteAsync_WithNestedShellWrapperContainingPrivilegeEscalation_ThrowsInvalidOperationExceptionAsync() + { + // Arrange - Nested shell wrapper with privilege escalation + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockedPaths = ["/etc"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ReturnsResultAsync() + { + // Arrange + var options = new ShellToolOptions + { + AllowedPaths = ["/tmp"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var options = new ShellToolOptions + { + AllowedPaths = ["/tmp"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockedPaths = ["/tmp/secret"], + AllowedPaths = ["/tmp"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._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_ReturnsResultAsync() + { + // Arrange + var options = new ShellToolOptions + { + BlockCommandChaining = false + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", s_catAnyPathFileCommand); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + [Theory] + [InlineData("cat ../../../etc/passwd")] + [InlineData("cat ./../../etc/passwd")] + [InlineData("ls ../secret")] + public async Task ExecuteAsync_WithRelativePathTraversal_ThrowsInvalidOperationExceptionAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + WorkingDirectory = "/tmp/safe", + AllowedPaths = ["/tmp/safe"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [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_ReturnsResultAsync(string command) + { + // Arrange + var options = new ShellToolOptions + { + WorkingDirectory = "/tmp/safe", + AllowedPaths = ["/tmp/safe"], + BlockCommandChaining = false + }; + var tool = new ShellTool(this._executorMock.Object, options); + var callContent = new ShellCallContent("call-1", [command]); + + // Act + var result = await tool.ExecuteAsync(callContent); + + // Assert + Assert.NotNull(result); + } + + #endregion +} 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..86d4d103b7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Shell.Local.IntegrationTests/LocalShellExecutorTests.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +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() + { + this._executor = new LocalShellExecutor(); + this._options = new ShellToolOptions + { + TimeoutInMilliseconds = 30000, + MaxOutputLength = 51200 + }; + } + + [Fact] + public async Task ExecuteAsync_WithSimpleEchoCommand_ReturnsExpectedOutputAsync() + { + // Arrange + const string Command = "echo hello"; + + // Act + var results = await this._executor.ExecuteAsync([Command], this._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_CapturesExitCodeAsync() + { + // Arrange + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd /c exit 42" + : "exit 42"; + + // Act + var results = await this._executor.ExecuteAsync([command], this._options); + + // Assert + Assert.Single(results); + Assert.Equal(42, results[0].ExitCode); + } + + [Fact] + public async Task ExecuteAsync_WithStderrOutput_CapturesStandardErrorAsync() + { + // Arrange + string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "cmd /c echo error message 1>&2" + : "echo error message >&2"; + + // Act + var results = await this._executor.ExecuteAsync([command], this._options); + + // Assert + Assert.Single(results); + Assert.Contains("ERROR", results[0].StandardError?.ToUpperInvariant() ?? string.Empty); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleCommands_ExecutesAllInSequenceAsync() + { + // Arrange + string[] commands = ["echo first", "echo second", "echo third"]; + + // Act + var results = await this._executor.ExecuteAsync(commands, this._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_UsesSpecifiedDirectoryAsync() + { + // 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 this._executor.ExecuteAsync([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_TimesOutLongRunningCommandAsync() + { + // 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 this._executor.ExecuteAsync([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_TruncatesLargeOutputAsync() + { + // 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 this._executor.ExecuteAsync([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_ReturnsErrorOrNonZeroExitCodeAsync() + { + // Act + var results = await this._executor.ExecuteAsync(s_nonExistentCommand, this._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_UsesSpecifiedShellAsync() + { + // 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 this._executor.ExecuteAsync(s_powershellCommand, options); + + // Assert + Assert.Single(results); + Assert.Contains("hello", results[0].StandardOutput); + } + + [Fact] + public async Task ExecuteAsync_WithCancellationToken_ThrowsOperationCanceledExceptionAsync() + { + // 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(() => + this._executor.ExecuteAsync([command], this._options, cts.Token)); + } + + [Fact] + public async Task ExecuteAsync_WithEmptyCommandList_ReturnsEmptyListAsync() + { + // Act + var results = await this._executor.ExecuteAsync([], this._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 + + + + + + +