Skip to content

Commit 310ccea

Browse files
feat: Task 2.6 - Implement ShellTool with security sandboxing
- Executes shell commands via /bin/bash -c - Enforces security policy via ISecurityPolicy interface - Configurable timeout (default 30s) with process termination - Output truncation at 10KB limit to prevent memory issues - Captures both stdout and stderr - Returns proper error codes and messages - All 8 unit tests passing with TDD methodology
1 parent fb5d81b commit 310ccea

4 files changed

Lines changed: 343 additions & 0 deletions

File tree

ClawSharp.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<Project Path="tests/ClawSharp.Core.Tests/ClawSharp.Core.Tests.csproj" />
1818
<Project Path="tests/ClawSharp.Infrastructure.Tests/ClawSharp.Infrastructure.Tests.csproj" />
1919
<Project Path="tests/ClawSharp.Providers.Tests/ClawSharp.Providers.Tests.csproj" />
20+
<Project Path="tests/ClawSharp.Tools.Tests/ClawSharp.Tools.Tests.csproj" />
2021
<Project Path="tests/ClawSharp.TestHelpers/ClawSharp.TestHelpers.csproj" />
2122
</Folder>
2223
</Solution>

src/ClawSharp.Tools/ShellTool.cs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
using System.Diagnostics;
2+
using System.Text;
3+
using System.Text.Json;
4+
using ClawSharp.Core.Security;
5+
using ClawSharp.Core.Tools;
6+
7+
namespace ClawSharp.Tools;
8+
9+
/// <summary>
10+
/// Tool for executing shell commands with security policy enforcement.
11+
/// </summary>
12+
public class ShellTool : ITool
13+
{
14+
private readonly ISecurityPolicy _securityPolicy;
15+
private readonly TimeSpan _timeout;
16+
private const int MaxOutputBytes = 10_240; // 10KB
17+
18+
public string Name => "shell";
19+
20+
public string Description => "Execute a shell command and return its output. Use this to interact with the system, run programs, check dates, list files, etc.";
21+
22+
public ToolSpec Specification => new(
23+
Name: "shell",
24+
Description: Description,
25+
ParametersSchema: JsonSerializer.Deserialize<JsonElement>("""
26+
{
27+
"type": "object",
28+
"properties": {
29+
"command": {
30+
"type": "string",
31+
"description": "The shell command to execute"
32+
}
33+
},
34+
"required": ["command"]
35+
}
36+
""")
37+
);
38+
39+
public ShellTool(ISecurityPolicy securityPolicy, TimeSpan? timeout = null)
40+
{
41+
_securityPolicy = securityPolicy ?? throw new ArgumentNullException(nameof(securityPolicy));
42+
_timeout = timeout ?? TimeSpan.FromSeconds(30);
43+
}
44+
45+
public async Task<ToolResult> ExecuteAsync(JsonElement arguments, CancellationToken ct = default)
46+
{
47+
// Extract command from arguments
48+
if (!arguments.TryGetProperty("command", out var commandElement))
49+
{
50+
return new ToolResult(false, "", "Missing required parameter: command");
51+
}
52+
53+
var command = commandElement.GetString();
54+
if (string.IsNullOrWhiteSpace(command))
55+
{
56+
return new ToolResult(false, "", "Command cannot be empty");
57+
}
58+
59+
// Check security policy
60+
if (!_securityPolicy.IsCommandAllowed(command))
61+
{
62+
return new ToolResult(false, "", $"Command not allowed by security policy: {command}");
63+
}
64+
65+
// Execute command
66+
try
67+
{
68+
var (exitCode, output, error) = await ExecuteCommandAsync(command, ct);
69+
70+
if (exitCode != 0)
71+
{
72+
var errorMessage = $"Command exited with exit code {exitCode}";
73+
if (!string.IsNullOrWhiteSpace(error))
74+
errorMessage += $"\nStderr: {error}";
75+
return new ToolResult(false, output, errorMessage);
76+
}
77+
78+
return new ToolResult(true, output);
79+
}
80+
catch (OperationCanceledException)
81+
{
82+
return new ToolResult(false, "", "Command execution timed out");
83+
}
84+
catch (Exception ex)
85+
{
86+
return new ToolResult(false, "", $"Error executing command: {ex.Message}");
87+
}
88+
}
89+
90+
private async Task<(int ExitCode, string Output, string Error)> ExecuteCommandAsync(string command, CancellationToken ct)
91+
{
92+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
93+
cts.CancelAfter(_timeout);
94+
95+
var processInfo = new ProcessStartInfo
96+
{
97+
FileName = "/bin/bash",
98+
Arguments = $"-c \"{command.Replace("\"", "\\\"")}\"",
99+
RedirectStandardOutput = true,
100+
RedirectStandardError = true,
101+
UseShellExecute = false,
102+
CreateNoWindow = true
103+
};
104+
105+
using var process = new Process { StartInfo = processInfo };
106+
process.Start();
107+
108+
// Read output and error with size limits
109+
var outputTask = ReadStreamWithLimitAsync(process.StandardOutput, MaxOutputBytes, cts.Token);
110+
var errorTask = ReadStreamWithLimitAsync(process.StandardError, MaxOutputBytes, cts.Token);
111+
112+
try
113+
{
114+
await process.WaitForExitAsync(cts.Token);
115+
}
116+
catch (OperationCanceledException)
117+
{
118+
// Timeout - kill the process
119+
try
120+
{
121+
process.Kill(entireProcessTree: true);
122+
}
123+
catch
124+
{
125+
// Process might have already exited
126+
}
127+
throw new OperationCanceledException("Command execution timed out");
128+
}
129+
130+
var output = await outputTask;
131+
var error = await errorTask;
132+
133+
// Combine stderr into output if there's no error
134+
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(error))
135+
{
136+
output = string.IsNullOrWhiteSpace(output) ? error : $"{output}\n{error}";
137+
error = "";
138+
}
139+
140+
return (process.ExitCode, output, error);
141+
}
142+
143+
private async Task<string> ReadStreamWithLimitAsync(StreamReader reader, int maxBytes, CancellationToken ct)
144+
{
145+
var builder = new StringBuilder();
146+
var bytesRead = 0;
147+
var truncated = false;
148+
149+
while (true)
150+
{
151+
var line = await reader.ReadLineAsync(ct);
152+
if (line == null)
153+
break;
154+
155+
var lineBytes = Encoding.UTF8.GetByteCount(line) + Environment.NewLine.Length;
156+
157+
if (bytesRead + lineBytes <= maxBytes)
158+
{
159+
builder.AppendLine(line);
160+
bytesRead += lineBytes;
161+
}
162+
else if (!truncated)
163+
{
164+
builder.AppendLine("\n[Output truncated - exceeded 10KB limit]");
165+
truncated = true;
166+
// Continue reading to drain the stream, but don't add more
167+
}
168+
}
169+
170+
return builder.ToString().TrimEnd();
171+
}
172+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="coverlet.collector" Version="6.0.4" />
12+
<PackageReference Include="FluentAssertions" Version="8.8.0" />
13+
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
14+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
15+
<PackageReference Include="NSubstitute" Version="5.3.0" />
16+
<PackageReference Include="xunit" Version="2.9.3" />
17+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<Using Include="Xunit" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\..\src\ClawSharp.Tools\ClawSharp.Tools.csproj" />
26+
<ProjectReference Include="..\..\src\ClawSharp.Core\ClawSharp.Core.csproj" />
27+
</ItemGroup>
28+
29+
</Project>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using System.Text.Json;
2+
using ClawSharp.Core.Security;
3+
using ClawSharp.Core.Tools;
4+
using FluentAssertions;
5+
using NSubstitute;
6+
7+
namespace ClawSharp.Tools.Tests;
8+
9+
public class ShellToolTests
10+
{
11+
[Fact]
12+
public async Task ExecuteAsync_AllowedCommand_ReturnsOutput()
13+
{
14+
// Arrange
15+
var security = Substitute.For<ISecurityPolicy>();
16+
security.IsCommandAllowed(Arg.Any<string>()).Returns(true);
17+
var tool = new ShellTool(security);
18+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "echo hello"}""");
19+
20+
// Act
21+
var result = await tool.ExecuteAsync(args);
22+
23+
// Assert
24+
result.Success.Should().BeTrue();
25+
result.Output.Should().Contain("hello");
26+
}
27+
28+
[Fact]
29+
public async Task ExecuteAsync_DisallowedCommand_ReturnsError()
30+
{
31+
// Arrange
32+
var security = Substitute.For<ISecurityPolicy>();
33+
security.IsCommandAllowed(Arg.Any<string>()).Returns(false);
34+
var tool = new ShellTool(security);
35+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "rm -rf /"}""");
36+
37+
// Act
38+
var result = await tool.ExecuteAsync(args);
39+
40+
// Assert
41+
result.Success.Should().BeFalse();
42+
result.Error.Should().Contain("not allowed");
43+
}
44+
45+
[Fact]
46+
public async Task ExecuteAsync_Timeout_KillsProcess()
47+
{
48+
// Arrange
49+
var security = Substitute.For<ISecurityPolicy>();
50+
security.IsCommandAllowed(Arg.Any<string>()).Returns(true);
51+
var tool = new ShellTool(security, TimeSpan.FromMilliseconds(100));
52+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "sleep 10"}""");
53+
54+
// Act
55+
var result = await tool.ExecuteAsync(args);
56+
57+
// Assert
58+
result.Success.Should().BeFalse();
59+
result.Error.Should().Contain("timed out");
60+
}
61+
62+
[Fact]
63+
public async Task ExecuteAsync_LargeOutput_Truncates()
64+
{
65+
// Arrange
66+
var security = Substitute.For<ISecurityPolicy>();
67+
security.IsCommandAllowed(Arg.Any<string>()).Returns(true);
68+
var tool = new ShellTool(security);
69+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "seq 1 100000"}""");
70+
71+
// Act
72+
var result = await tool.ExecuteAsync(args);
73+
74+
// Assert
75+
result.Output.Length.Should().BeLessThanOrEqualTo(11_000); // 10KB + truncation message
76+
}
77+
78+
[Fact]
79+
public void Specification_HasCorrectSchema()
80+
{
81+
// Arrange
82+
var security = Substitute.For<ISecurityPolicy>();
83+
var tool = new ShellTool(security);
84+
85+
// Act & Assert
86+
tool.Name.Should().Be("shell");
87+
tool.Specification.Name.Should().Be("shell");
88+
tool.Description.Should().NotBeEmpty();
89+
}
90+
91+
[Fact]
92+
public async Task ExecuteAsync_MissingCommandParameter_ReturnsError()
93+
{
94+
// Arrange
95+
var security = Substitute.For<ISecurityPolicy>();
96+
var tool = new ShellTool(security);
97+
var args = JsonSerializer.Deserialize<JsonElement>("""{}""");
98+
99+
// Act
100+
var result = await tool.ExecuteAsync(args);
101+
102+
// Assert
103+
result.Success.Should().BeFalse();
104+
result.Error.Should().Contain("command");
105+
}
106+
107+
[Fact]
108+
public async Task ExecuteAsync_NonZeroExitCode_IncludesExitCodeInOutput()
109+
{
110+
// Arrange
111+
var security = Substitute.For<ISecurityPolicy>();
112+
security.IsCommandAllowed(Arg.Any<string>()).Returns(true);
113+
var tool = new ShellTool(security);
114+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "exit 42"}""");
115+
116+
// Act
117+
var result = await tool.ExecuteAsync(args);
118+
119+
// Assert
120+
result.Success.Should().BeFalse();
121+
result.Error.Should().Contain("exit code");
122+
result.Error.Should().Contain("42");
123+
}
124+
125+
[Fact]
126+
public async Task ExecuteAsync_CapturesStderr()
127+
{
128+
// Arrange
129+
var security = Substitute.For<ISecurityPolicy>();
130+
security.IsCommandAllowed(Arg.Any<string>()).Returns(true);
131+
var tool = new ShellTool(security);
132+
var args = JsonSerializer.Deserialize<JsonElement>("""{"command": "echo error >&2"}""");
133+
134+
// Act
135+
var result = await tool.ExecuteAsync(args);
136+
137+
// Assert
138+
result.Success.Should().BeTrue();
139+
result.Output.Should().Contain("error");
140+
}
141+
}

0 commit comments

Comments
 (0)