210 lines
9.2 KiB
C#
210 lines
9.2 KiB
C#
namespace ClawdDotNet.Core.Sandbox;
|
|
|
|
/// <summary>
|
|
/// #654: What may run in the sandbox, and what it may see.
|
|
///
|
|
/// <para><b>Only the workspace is there.</b> It is mounted at <see cref="SandboxPaths.Work"/>
|
|
/// and is the only writable place besides a private <c>/tmp</c>. The system is read-only;
|
|
/// <c>$HOME</c>, the instance directory, the key ring and other agents' workspaces do not
|
|
/// exist in the sandbox — not blocked, absent.</para>
|
|
/// </summary>
|
|
public sealed record SandboxRequest
|
|
{
|
|
/// <summary>The program, as the sandbox sees it: <c>ffmpeg</c> (from the system's PATH) or an absolute path.</summary>
|
|
public required string Executable { get; init; }
|
|
|
|
public IReadOnlyList<string> Arguments { get; init; } = [];
|
|
|
|
/// <summary>The agent's workspace on the host — mounted writable at <c>/work</c>.</summary>
|
|
public required string WorkspacePath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Paths inside the workspace (relative) that the sandbox may read but not write —
|
|
/// whatever the main process reads as configuration or instruction. A sandbox that may
|
|
/// write what is later executed or obeyed outside is a delayed escape (CVE-2026-25725).
|
|
/// </summary>
|
|
public IReadOnlyList<string> ProtectedPaths { get; init; } = SandboxPaths.DefaultProtected;
|
|
|
|
/// <summary>Further host paths made visible read-only, at the same path — e.g. a program outside /usr.</summary>
|
|
public IReadOnlyList<string> ReadOnlyPaths { get; init; } = [];
|
|
|
|
/// <summary>The environment — only this. Nothing of the main process's is inherited.</summary>
|
|
public IReadOnlyDictionary<string, string> Environment { get; init; } = new Dictionary<string, string>();
|
|
|
|
public SandboxLimits Limits { get; init; } = new();
|
|
|
|
public string? StandardInput { get; init; }
|
|
|
|
/// <summary>
|
|
/// #712: Further host paths at a place of their own — a runtime's home, the MCP socket.
|
|
/// Applied after the workspace; a target inside <c>/work</c> is refused.
|
|
/// </summary>
|
|
public IReadOnlyList<SandboxMount> Mounts { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// #712/#655: The hosts the run may reach, as <c>host</c> or <c>host:port</c> (443 if
|
|
/// none), <c>*.example.com</c> for subdomains. Empty: no network at all. Otherwise the
|
|
/// sandbox still has no network of its own; an HTTPS proxy in the main process opens
|
|
/// only these, and only to public addresses (<see cref="EgressProxy"/>).
|
|
/// </summary>
|
|
public IReadOnlyList<string> Egress { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// #712: Environment the program needs but that must not stand on a command line — an
|
|
/// API key. Handed over in a file only the sandbox's launcher reads, never through argv.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> SecretEnvironment { get; init; } = new Dictionary<string, string>();
|
|
|
|
/// <summary>Whether the run needs the launcher inside the sandbox (<see cref="SandboxLauncher"/>).</summary>
|
|
public bool NeedsLauncher => Egress.Count > 0 || SecretEnvironment.Count > 0;
|
|
}
|
|
|
|
/// <summary>#712: A host path mounted at <paramref name="Target"/> in the sandbox.</summary>
|
|
public sealed record SandboxMount(string HostPath, string Target, bool Writable = false);
|
|
|
|
/// <summary>
|
|
/// #712: The program that starts the work inside the sandbox when it needs more than an
|
|
/// <c>exec</c> — the application itself, called with <see cref="SandboxLauncher.Argument"/>.
|
|
/// It is mounted read-only with what it needs to start.
|
|
/// </summary>
|
|
public sealed record SandboxHelper(
|
|
string Executable,
|
|
IReadOnlyList<string> ReadOnlyPaths,
|
|
IReadOnlyDictionary<string, string> Environment)
|
|
{
|
|
/// <summary>
|
|
/// This application as the helper: its directory, and the .NET it runs on when that is
|
|
/// not under <c>/usr</c>. <c>null</c> when there is no executable to call (a test host).
|
|
/// </summary>
|
|
public static SandboxHelper? ForThisProcess()
|
|
{
|
|
var executable = System.Environment.ProcessPath;
|
|
if (string.IsNullOrEmpty(executable) || !File.Exists(executable))
|
|
return null;
|
|
var name = Path.GetFileNameWithoutExtension(executable);
|
|
return name is "dotnet" or "testhost" ? null : For(executable);
|
|
}
|
|
|
|
/// <summary>The helper at <paramref name="executable"/>, running on the same .NET as this process.</summary>
|
|
public static SandboxHelper For(string executable)
|
|
{
|
|
var paths = new List<string> { Path.GetDirectoryName(Path.GetFullPath(executable))! };
|
|
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
// .NET reserves address space by the gigabyte; under a memory limit it would not start.
|
|
["DOTNET_GCHeapHardLimit"] = "0x20000000",
|
|
["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
|
|
};
|
|
// <root>/shared/Microsoft.NETCore.App/<version>/ — the root is three levels up.
|
|
var runtime = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
|
|
var root = Path.GetFullPath(Path.Combine(runtime, "..", "..", ".."));
|
|
if (!root.StartsWith("/usr/", StringComparison.Ordinal) && Directory.Exists(root))
|
|
paths.Add(root);
|
|
environment["DOTNET_ROOT"] = root;
|
|
return new SandboxHelper(Path.GetFullPath(executable), paths, environment);
|
|
}
|
|
}
|
|
|
|
/// <summary>#654: The limits of one run.</summary>
|
|
public sealed record SandboxLimits
|
|
{
|
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>
|
|
/// Address space per process; 0 for none. A JavaScript runtime (Claude Code) reserves
|
|
/// tens of gigabytes it never uses and does not start under any sensible limit.
|
|
/// </summary>
|
|
public long MaxMemoryBytes { get; init; } = 2L * 1024 * 1024 * 1024;
|
|
|
|
/// <summary>Processes and threads the run may have at once — a fork bomb stops here.</summary>
|
|
public int MaxProcesses { get; init; } = 64;
|
|
|
|
/// <summary>The largest file the run may write.</summary>
|
|
public long MaxFileBytes { get; init; } = 1024L * 1024 * 1024;
|
|
|
|
/// <summary>Stdout and stderr together; beyond it the run is stopped.</summary>
|
|
public int MaxOutputBytes { get; init; } = 1024 * 1024;
|
|
|
|
public int MaxOpenFiles { get; init; } = 256;
|
|
}
|
|
|
|
public sealed record SandboxResult
|
|
{
|
|
public int ExitCode { get; init; }
|
|
public string StandardOutput { get; init; } = "";
|
|
public string StandardError { get; init; } = "";
|
|
public bool TimedOut { get; init; }
|
|
public bool OutputTruncated { get; init; }
|
|
|
|
public bool Succeeded => ExitCode == 0 && !TimedOut && !OutputTruncated;
|
|
}
|
|
|
|
/// <summary>
|
|
/// #654: Runs code outside the main process. The one way code runs — shell (#196), approved
|
|
/// scripts, MCP stdio servers (#197), the media tools.
|
|
/// </summary>
|
|
public interface ISandboxRunner
|
|
{
|
|
/// <summary>Whether code can run at all. Without a sandbox it does not — the function is missing, not weaker.</summary>
|
|
bool IsAvailable { get; }
|
|
|
|
/// <summary>Why not, if <see cref="IsAvailable"/> is false.</summary>
|
|
string? UnavailableReason { get; }
|
|
|
|
Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #654: A tool that runs programs — it is handed the instance's sandbox at start. Also for
|
|
/// work that runs without a tool context (jobs), where <c>AgentToolContext.Sandbox</c> is not there.
|
|
/// </summary>
|
|
public interface ISandboxUser
|
|
{
|
|
ISandboxRunner? Sandbox { get; set; }
|
|
}
|
|
|
|
/// <summary>#654: Where there is no sandbox (Windows, or no bwrap): nothing runs.</summary>
|
|
public sealed class NoSandbox(string reason) : ISandboxRunner
|
|
{
|
|
public bool IsAvailable => false;
|
|
public string? UnavailableReason => reason;
|
|
|
|
public Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct)
|
|
=> throw new SandboxUnavailableException(reason);
|
|
}
|
|
|
|
public sealed class SandboxUnavailableException(string reason)
|
|
: InvalidOperationException($"Code execution is not available: {reason}");
|
|
|
|
/// <summary>#654: Paths as the sandbox sees them.</summary>
|
|
public static class SandboxPaths
|
|
{
|
|
public const string Work = "/work";
|
|
|
|
/// <summary>#712: Where the launcher finds what the runner hands it.</summary>
|
|
public const string Run = "/run/clawd";
|
|
public const string EgressSocket = Run + "/egress.sock";
|
|
public const string SecretFile = Run + "/env.json";
|
|
|
|
/// <summary>#712: The proxy's port on the sandbox's own loopback.</summary>
|
|
public const int ProxyPort = 3128;
|
|
|
|
/// <summary>
|
|
/// In the workspace, read-only by default: <c>scripts/</c> holds approved scripts and their
|
|
/// manifests (#695) — what is checked must stay what runs.
|
|
/// </summary>
|
|
public static readonly IReadOnlyList<string> DefaultProtected = ["scripts"];
|
|
|
|
/// <summary>A host path inside the workspace, as the sandbox sees it; <c>null</c> if it lies outside.</summary>
|
|
public static string? InSandbox(string workspace, string hostPath)
|
|
{
|
|
var root = Path.GetFullPath(workspace).TrimEnd('/', '\\');
|
|
var full = Path.GetFullPath(hostPath);
|
|
if (string.Equals(full.TrimEnd('/', '\\'), root, StringComparison.Ordinal))
|
|
return Work;
|
|
if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal))
|
|
return null;
|
|
return Work + "/" + full[(root.Length + 1)..].Replace('\\', '/');
|
|
}
|
|
}
|