Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions JD.AI.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Project Path="src/JD.AI.Channels.OpenClaw/JD.AI.Channels.OpenClaw.csproj" />
<Project Path="src/JD.AI.Workflows.Distributed/JD.AI.Workflows.Distributed.csproj" />
<Project Path="src/JD.AI.Workflows/JD.AI.Workflows.csproj" />
<Project Path="src/JD.AI.Sandbox/JD.AI.Sandbox.csproj" />
<Project Path="src/JD.AI.SpecSite/JD.AI.SpecSite.csproj" />
<Project Path="src/JD.AI/JD.AI.csproj" />
</Folder>
Expand All @@ -28,6 +29,7 @@
<Project Path="tests/JD.AI.Daemon.Tests/JD.AI.Daemon.Tests.csproj" />
<Project Path="tests/JD.AI.Gateway.Tests/JD.AI.Gateway.Tests.csproj" />
<Project Path="tests/JD.AI.Specs/JD.AI.Specs.csproj" />
<Project Path="tests/JD.AI.Sandbox.Tests/JD.AI.Sandbox.Tests.csproj" />
<Project Path="tests/JD.AI.Specs.UI/JD.AI.Specs.UI.csproj" />
</Folder>
</Solution>
49 changes: 49 additions & 0 deletions src/JD.AI.Sandbox/Abstractions/ISandbox.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace JD.AI.Sandbox.Abstractions;

/// <summary>
/// Contract for a process isolation layer that enforces capability restrictions
/// on a child process based on a <see cref="SandboxPolicy"/>.
/// </summary>
public interface ISandbox
{
/// <summary>The policy this sandbox enforces.</summary>
SandboxPolicy Policy { get; }

/// <summary>Which platform this sandbox targets.</summary>
SandboxPlatform Platform { get; }

/// <summary>
/// Starts a new sandboxed process using the configured policy.
/// </summary>
/// <param name="executablePath">Path to the executable to run.</param>
/// <param name="arguments">Command-line arguments.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A handle to the running sandboxed process.</returns>
Task<SandboxedProcess> StartAsync(
string executablePath,
string arguments = "",
CancellationToken ct = default);

/// <summary>
/// Runs the sandboxed process to completion and returns the result.
/// </summary>
Task<SandboxExecutionResult> RunAsync(
string executablePath,
string arguments = "",
CancellationToken ct = default);
}

/// <summary>
/// Target platform for sandbox enforcement.
/// </summary>
public enum SandboxPlatform
{
/// <summary>Linux with Landlock LSM + seccomp-bpf.</summary>
Linux,

/// <summary>Windows with Job Objects + Restricted Tokens.</summary>
Windows,

/// <summary>Cross-platform using simulated isolation (no real OS enforcement).</summary>
None,
}
129 changes: 129 additions & 0 deletions src/JD.AI.Sandbox/Abstractions/ISandboxPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Diagnostics;

namespace JD.AI.Sandbox.Abstractions;

/// <summary>
/// Defines the capability profile for a sandboxed process.
/// Each policy describes what resources and operations are allowed or denied.
/// </summary>
public sealed class SandboxPolicy
{
/// <summary>Human-readable name for this policy (e.g., "PlannerPolicy", "ExecutorPolicy").</summary>
public string Name { get; init; } = string.Empty;

/// <summary>Whether the sandboxed process can make outbound network connections.</summary>
public bool AllowNetwork { get; init; } = true;

/// <summary>Whether the sandboxed process can read from the filesystem.</summary>
public bool AllowRead { get; init; } = true;

/// <summary>Whether the sandboxed process can write to the filesystem.</summary>
public bool AllowWrite { get; init; } = true;

/// <summary>
/// Explicitly allowed filesystem paths (if non-empty, all other paths are denied for read/write).
/// Supports glob patterns. Only meaningful when <see cref="AllowRead"/> or <see cref="AllowWrite"/> is true.
/// </summary>
public IReadOnlyList<string> AllowedPaths { get; init; } = [];

/// <summary>
/// Explicitly denied filesystem paths. Takes precedence over <see cref="AllowedPaths"/>.
/// Supports glob patterns.
/// </summary>
public IReadOnlyList<string> DeniedPaths { get; init; } = [];

/// <summary>Whether the sandboxed process can spawn child processes.</summary>
public bool AllowProcessSpawn { get; init; }

/// <summary>Maximum CPU time allowed (in milliseconds) per execution. null = unlimited.</summary>
public int? MaxCpuTimeMs { get; init; }

/// <summary>Maximum memory allowed (in bytes) per execution. null = unlimited.</summary>
public long? MaxMemoryBytes { get; init; }

/// <summary>
/// Environment variables that will be passed to the sandboxed process.
/// Empty = inherit all from parent.
/// </summary>
public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; init; } = new Dictionary<string, string?>();

/// <summary>
/// Working directory for the sandboxed process.
/// null = inherit from parent.
/// </summary>
public string? WorkingDirectory { get; init; }
}

/// <summary>
/// Represents a started sandboxed process.
/// </summary>
public sealed class SandboxedProcess : IAsyncDisposable
{
/// <summary>Process ID of the sandboxed process.</summary>
public int ProcessId { get; }

/// <summary>Standard input stream to the sandboxed process.</summary>
public StreamWriter StandardInput => _standardInput;

/// <summary>Standard output stream from the sandboxed process.</summary>
public StreamReader StandardOutput => _standardOutput;

/// <summary>Standard error stream from the sandboxed process.</summary>
public StreamReader StandardError => _standardError;

private readonly Process _process;
private readonly StreamWriter _standardInput;
private readonly StreamReader _standardOutput;
private readonly StreamReader _standardError;
private bool _disposed;

internal SandboxedProcess(Process process, StreamWriter standardInput, StreamReader standardOutput, StreamReader standardError)
{
_process = process;
_standardInput = standardInput;
_standardOutput = standardOutput;
_standardError = standardError;
ProcessId = process.Id;
}

/// <summary>Waits for the process to exit.</summary>
public async Task WaitForExitAsync(CancellationToken ct = default)
{
await _process.WaitForExitAsync(ct).ConfigureAwait(false);
}

/// <summary>Gets the process exit code.</summary>
public int ExitCode => _process.ExitCode;

/// <summary>Kills the sandboxed process and all its children.</summary>
public void Kill()
{
try { _process.Kill(entireProcessTree: true); } catch { /* ignore */ }
}

/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;

_standardInput.Dispose();
_standardOutput.Dispose();
_standardError.Dispose();
_process.Dispose();
await ValueTask.CompletedTask;
}
}

/// <summary>
/// Result of a sandboxed execution.
/// </summary>
public sealed class SandboxExecutionResult
{
public bool Success { get; init; }
public int ExitCode { get; init; }
public string StandardOutput { get; init; } = string.Empty;
public string StandardError { get; init; } = string.Empty;
public TimeSpan Elapsed { get; init; }
public string? Error { get; init; }
}
102 changes: 102 additions & 0 deletions src/JD.AI.Sandbox/Abstractions/NoneSandbox.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Diagnostics;

namespace JD.AI.Sandbox.Abstractions;

/// <summary>
/// A no-op sandbox that runs processes without any OS-level isolation.
/// Used as fallback when platform-specific sandboxing is unavailable,
/// or for testing where isolation is not required.
/// </summary>
public sealed class NoneSandbox : ISandbox
{
public SandboxPolicy Policy { get; }
public SandboxPlatform Platform => SandboxPlatform.None;

public NoneSandbox(SandboxPolicy policy)
{
Policy = policy;
}

/// <inheritdoc/>
public async Task<SandboxedProcess> StartAsync(
string executablePath,
string arguments = "",
CancellationToken ct = default)
{
var psi = BuildStartInfo(executablePath, arguments);
var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start process: {executablePath}");

return new SandboxedProcess(
process,
new StreamWriter(process.StandardInput.BaseStream, leaveOpen: true),
new StreamReader(process.StandardOutput.BaseStream, leaveOpen: true),
new StreamReader(process.StandardError.BaseStream, leaveOpen: true));
}

/// <inheritdoc/>
public async Task<SandboxExecutionResult> RunAsync(
string executablePath,
string arguments = "",
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
try
{
var psi = BuildStartInfo(executablePath, arguments);
var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start process: {executablePath}");

var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
var error = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);

return new SandboxExecutionResult
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
StandardOutput = output,
StandardError = error,
Elapsed = sw.Elapsed,
};
}
catch (Exception ex)
{
return new SandboxExecutionResult
{
Success = false,
ExitCode = -1,
Elapsed = sw.Elapsed,
Error = ex.Message,
};
}
}

private ProcessStartInfo BuildStartInfo(string executablePath, string arguments)
{
var psi = new ProcessStartInfo
{
FileName = executablePath,
Arguments = arguments,
WorkingDirectory = Policy.WorkingDirectory ?? Environment.CurrentDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};

// Merge policy environment with current environment
if (Policy.EnvironmentVariables.Count > 0)
{
foreach (var kv in Policy.EnvironmentVariables)
{
if (kv.Value is null)
psi.Environment.Remove(kv.Key);
else
psi.Environment[kv.Key] = kv.Value;
}
}

return psi;
}
}
19 changes: 19 additions & 0 deletions src/JD.AI.Sandbox/JD.AI.Sandbox.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>JD.AI.Sandbox</RootNamespace>
<Description>Process isolation layer for JD.AI — planner/executor sandbox with no third-party dependencies. Uses OS-native primitives (Landlock + seccomp on Linux, Job Objects + Restricted Tokens on Windows).</Description>
<PackageTags>security;sandbox;isolation;landlock;seccomp;job-objects</PackageTags>
<NoWarn>$(NoWarn);CA5392</NoWarn>
</PropertyGroup>

<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="JD.AI.Sandbox.Tests" />
</ItemGroup>

</Project>
Loading
Loading