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
+
+
+
+
+
+
+