Files
ClawdDotNet/src/ClawdDotNet.Core/Sandbox/SandboxModels.cs
T

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('\\', '/');
}
}