namespace ClawdDotNet.Core.Sandbox;
///
/// #654: What may run in the sandbox, and what it may see.
///
/// Only the workspace is there. It is mounted at
/// and is the only writable place besides a private /tmp. The system is read-only;
/// $HOME, the instance directory, the key ring and other agents' workspaces do not
/// exist in the sandbox — not blocked, absent.
///
public sealed record SandboxRequest
{
/// The program, as the sandbox sees it: ffmpeg (from the system's PATH) or an absolute path.
public required string Executable { get; init; }
public IReadOnlyList Arguments { get; init; } = [];
/// The agent's workspace on the host — mounted writable at /work.
public required string WorkspacePath { get; init; }
///
/// 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).
///
public IReadOnlyList ProtectedPaths { get; init; } = SandboxPaths.DefaultProtected;
/// Further host paths made visible read-only, at the same path — e.g. a program outside /usr.
public IReadOnlyList ReadOnlyPaths { get; init; } = [];
/// The environment — only this. Nothing of the main process's is inherited.
public IReadOnlyDictionary Environment { get; init; } = new Dictionary();
public SandboxLimits Limits { get; init; } = new();
public string? StandardInput { get; init; }
///
/// #712: Further host paths at a place of their own — a runtime's home, the MCP socket.
/// Applied after the workspace; a target inside /work is refused.
///
public IReadOnlyList Mounts { get; init; } = [];
///
/// #712/#655: The hosts the run may reach, as host or host:port (443 if
/// none), *.example.com 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 ().
///
public IReadOnlyList Egress { get; init; } = [];
///
/// #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.
///
public IReadOnlyDictionary SecretEnvironment { get; init; } = new Dictionary();
/// Whether the run needs the launcher inside the sandbox ().
public bool NeedsLauncher => Egress.Count > 0 || SecretEnvironment.Count > 0;
}
/// #712: A host path mounted at in the sandbox.
public sealed record SandboxMount(string HostPath, string Target, bool Writable = false);
///
/// #712: The program that starts the work inside the sandbox when it needs more than an
/// exec — the application itself, called with .
/// It is mounted read-only with what it needs to start.
///
public sealed record SandboxHelper(
string Executable,
IReadOnlyList ReadOnlyPaths,
IReadOnlyDictionary Environment)
{
///
/// This application as the helper: its directory, and the .NET it runs on when that is
/// not under /usr. null when there is no executable to call (a test host).
///
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);
}
/// The helper at , running on the same .NET as this process.
public static SandboxHelper For(string executable)
{
var paths = new List { Path.GetDirectoryName(Path.GetFullPath(executable))! };
var environment = new Dictionary(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"
};
// /shared/Microsoft.NETCore.App// — 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);
}
}
/// #654: The limits of one run.
public sealed record SandboxLimits
{
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
///
/// 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.
///
public long MaxMemoryBytes { get; init; } = 2L * 1024 * 1024 * 1024;
/// Processes and threads the run may have at once — a fork bomb stops here.
public int MaxProcesses { get; init; } = 64;
/// The largest file the run may write.
public long MaxFileBytes { get; init; } = 1024L * 1024 * 1024;
/// Stdout and stderr together; beyond it the run is stopped.
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;
}
///
/// #654: Runs code outside the main process. The one way code runs — shell (#196), approved
/// scripts, MCP stdio servers (#197), the media tools.
///
public interface ISandboxRunner
{
/// Whether code can run at all. Without a sandbox it does not — the function is missing, not weaker.
bool IsAvailable { get; }
/// Why not, if is false.
string? UnavailableReason { get; }
Task RunAsync(SandboxRequest request, CancellationToken ct);
}
///
/// #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 AgentToolContext.Sandbox is not there.
///
public interface ISandboxUser
{
ISandboxRunner? Sandbox { get; set; }
}
/// #654: Where there is no sandbox (Windows, or no bwrap): nothing runs.
public sealed class NoSandbox(string reason) : ISandboxRunner
{
public bool IsAvailable => false;
public string? UnavailableReason => reason;
public Task RunAsync(SandboxRequest request, CancellationToken ct)
=> throw new SandboxUnavailableException(reason);
}
public sealed class SandboxUnavailableException(string reason)
: InvalidOperationException($"Code execution is not available: {reason}");
/// #654: Paths as the sandbox sees them.
public static class SandboxPaths
{
public const string Work = "/work";
/// #712: Where the launcher finds what the runner hands it.
public const string Run = "/run/clawd";
public const string EgressSocket = Run + "/egress.sock";
public const string SecretFile = Run + "/env.json";
/// #712: The proxy's port on the sandbox's own loopback.
public const int ProxyPort = 3128;
///
/// In the workspace, read-only by default: scripts/ holds approved scripts and their
/// manifests (#695) — what is checked must stay what runs.
///
public static readonly IReadOnlyList DefaultProtected = ["scripts"];
/// A host path inside the workspace, as the sandbox sees it; null if it lies outside.
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('\\', '/');
}
}