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