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