diff --git a/VolcanicAgenticCore.slnx b/VolcanicAgenticCore.slnx index d35a7d9..466c401 100644 --- a/VolcanicAgenticCore.slnx +++ b/VolcanicAgenticCore.slnx @@ -17,6 +17,7 @@ + diff --git a/src/Volcanic.AgenticCore/Features/Processes/HostProcessHardening.cs b/src/Volcanic.AgenticCore/Features/Processes/HostProcessHardening.cs new file mode 100644 index 0000000..b4c0664 --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/Processes/HostProcessHardening.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Volcanic.AgenticCore.Features.Processes; + +/// +/// Linux: makes the host non-dumpable (prctl(PR_SET_DUMPABLE, 0)) so a same-user child can't read +/// /proc/<pid>/environ or ptrace the host (spec ยง8). Idempotent: the first call wins. +/// +public static partial class HostProcessHardening +{ + private const int PrSetDumpable = 4; + private static readonly Hardener Shared = new(SetDumpable, OperatingSystem.IsLinux(), Environment.GetEnvironmentVariable); + + /// Applies the hardening once per process; later calls are no-ops whatever their options say. + public static void Apply(ProcessEnvironmentOptions options, ILogger logger) => Shared.Apply(options, logger); + + [LibraryImport("libc", SetLastError = true)] + private static partial int prctl(int option, nuint arg2, nuint arg3, nuint arg4, nuint arg5); + + private static int SetDumpable(int value) => + prctl(PrSetDumpable, (nuint)value, 0, 0, 0) == 0 ? 0 : Marshal.GetLastPInvokeError(); + + /// The testable core: setDumpable returns 0 on success, else the errno. + internal sealed class Hardener(Func setDumpable, bool isLinux, Func getEnv) + { + private int _applied; + + public void Apply(ProcessEnvironmentOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + if (Interlocked.Exchange(ref _applied, 1) == 1) return; + if (!options.HardenHostProcess) + { + logger.LogWarning("Host process hardening disabled: agent processes can read this process's environment"); + return; + } + if (!isLinux) + { + logger.LogDebug("Host process hardening skipped: only Linux needs it"); + return; + } + var errno = setDumpable(0); + if (errno == 0) logger.LogInformation("Host process hardened: not dumpable"); + else logger.LogWarning("Host process hardening failed: prctl(PR_SET_DUMPABLE, 0) failed with errno {Errno}", errno); + if (getEnv("DOTNET_EnableDiagnostics") != "0" && getEnv("DOTNET_EnableDiagnostics_IPC") != "0") + logger.LogWarning("The .NET diagnostic IPC socket is on and hands out this process's environment to the same user; set DOTNET_EnableDiagnostics=0 before starting the host"); + } + } +} diff --git a/src/Volcanic.AgenticCore/Features/Processes/ProcessSpawnPolicy.cs b/src/Volcanic.AgenticCore/Features/Processes/ProcessSpawnPolicy.cs index a641081..0674276 100644 --- a/src/Volcanic.AgenticCore/Features/Processes/ProcessSpawnPolicy.cs +++ b/src/Volcanic.AgenticCore/Features/Processes/ProcessSpawnPolicy.cs @@ -27,6 +27,7 @@ public sealed class ProcessSpawnPolicy : IProcessSpawnPolicy _environment = environment ?? CurrentEnvironment; _isWindows = isWindows; _filter = new EnvironmentFilter(options); + HostProcessHardening.Apply(options, _logger); } public ProcessSpawnContext Prepare(ProcessSpawnRequest request) diff --git a/src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj b/src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj index 465a0d5..fd4ab86 100644 --- a/src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj +++ b/src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj @@ -1,6 +1,8 @@ Headless, extensible agent runtime (pi clone) organised as vertical slices. + + true diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/NoHostHardening.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/NoHostHardening.cs new file mode 100644 index 0000000..26311b4 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/NoHostHardening.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging.Abstractions; +using Volcanic.AgenticCore.Features.Processes; + +namespace Volcanic.AgenticCore.Extensions.Tests; + +/// Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable. +internal static class NoHostHardening +{ + [ModuleInitializer] + internal static void Disable() => + HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance); +} diff --git a/tests/Volcanic.AgenticCore.HardeningProbe/Program.cs b/tests/Volcanic.AgenticCore.HardeningProbe/Program.cs new file mode 100644 index 0000000..6263ef8 --- /dev/null +++ b/tests/Volcanic.AgenticCore.HardeningProbe/Program.cs @@ -0,0 +1,11 @@ +// Test helper: `dotnet Volcanic.AgenticCore.HardeningProbe.dll harden|plain`. Prints "ok" once hardening ran, then +// waits until stdin closes so the test can read /proc//environ in between. +using Microsoft.Extensions.Logging.Abstractions; +using Volcanic.AgenticCore.Features.Processes; + +var harden = args.Length > 0 && args[0] == "harden"; +HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = harden }, NullLogger.Instance); +Console.WriteLine("ok"); +Console.Out.Flush(); +await Console.In.ReadToEndAsync(); +return 0; diff --git a/tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj b/tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj new file mode 100644 index 0000000..5fe2e14 --- /dev/null +++ b/tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj @@ -0,0 +1,11 @@ + + + Exe + false + false + Helper for HostProcessHardeningTests: applies hardening, prints ok, waits for stdin to close. + + + + + diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/NoHostHardening.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/NoHostHardening.cs new file mode 100644 index 0000000..3cb8ff5 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/NoHostHardening.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging.Abstractions; +using Volcanic.AgenticCore.Features.Processes; + +namespace Volcanic.AgenticCore.Telegram.Tests.Support; + +/// Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable. +internal static class NoHostHardening +{ + [ModuleInitializer] + internal static void Disable() => + HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance); +} diff --git a/tests/Volcanic.AgenticCore.Tests/Processes/HostProcessHardeningTests.cs b/tests/Volcanic.AgenticCore.Tests/Processes/HostProcessHardeningTests.cs new file mode 100644 index 0000000..78baa93 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Processes/HostProcessHardeningTests.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Volcanic.AgenticCore.Features.Processes; +using Volcanic.AgenticCore.Tests.Support; + +namespace Volcanic.AgenticCore.Tests.Processes; + +public class HostProcessHardeningTests +{ + private static readonly ProcessEnvironmentOptions On = new(); + private static readonly ProcessEnvironmentOptions Off = new() { HardenHostProcess = false }; + private static Func Env(string? diagnostics = "0", string? ipc = null) => + name => name switch { "DOTNET_EnableDiagnostics" => diagnostics, "DOTNET_EnableDiagnostics_IPC" => ipc, _ => null }; + + [Fact] + public void Linux_with_the_flag_on_calls_prctl_with_zero() + { + var calls = new List(); + var logger = new RecordingLogger(); + new HostProcessHardening.Hardener(v => { calls.Add(v); return 0; }, isLinux: true, Env()).Apply(On, logger); + Assert.Equal([0], calls); + Assert.True(logger.Has(LogLevel.Information, "Host process hardened: not dumpable")); + } + + [Fact] + public void A_second_apply_does_not_call_prctl_again() + { + var calls = 0; + var hardener = new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: true, Env()); + hardener.Apply(On, new RecordingLogger()); + hardener.Apply(On, new RecordingLogger()); + Assert.Equal(1, calls); + } + + [Fact] + public void A_failing_prctl_logs_a_warning_and_does_not_throw() + { + var logger = new RecordingLogger(); + new HostProcessHardening.Hardener(_ => 22, isLinux: true, Env()).Apply(On, logger); + Assert.True(logger.Has(LogLevel.Warning, "prctl(PR_SET_DUMPABLE, 0) failed")); + Assert.True(logger.Has(LogLevel.Warning, "22")); + } + + [Fact] + public void Non_linux_does_not_call_prctl() + { + var calls = 0; + new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: false, Env(null)).Apply(On, new RecordingLogger()); + Assert.Equal(0, calls); + } + + [Fact] + public void Flag_off_does_not_call_prctl_and_logs_a_warning() + { + var calls = 0; + var logger = new RecordingLogger(); + new HostProcessHardening.Hardener(_ => { calls++; return 0; }, isLinux: true, Env()).Apply(Off, logger); + Assert.Equal(0, calls); + Assert.True(logger.Has(LogLevel.Warning, "Host process hardening disabled")); + } + + [Fact] + public void Missing_DOTNET_EnableDiagnostics_logs_a_warning() + { + var logger = new RecordingLogger(); + new HostProcessHardening.Hardener(_ => 0, isLinux: true, Env(diagnostics: null)).Apply(On, logger); + Assert.True(logger.Has(LogLevel.Warning, "DOTNET_EnableDiagnostics=0")); + } + + [Theory] + [InlineData("0", null)] + [InlineData(null, "0")] + public void DOTNET_EnableDiagnostics_zero_suppresses_the_warning(string? diagnostics, string? ipc) + { + var logger = new RecordingLogger(); + new HostProcessHardening.Hardener(_ => 0, isLinux: true, Env(diagnostics, ipc)).Apply(On, logger); + Assert.False(logger.Has(LogLevel.Warning, "DOTNET_EnableDiagnostics")); + } + + [Fact] + public async Task Hardened_process_environ_is_unreadable_to_the_same_user() + { + if (!OperatingSystem.IsLinux()) return; + if (Environment.UserName == "root") return; // root reads every /proc entry; the guarantee is for the bot's own user + + var (hardenedExit, _, hardenedErr) = await ReadProbeEnvironAsync(harden: true); + Assert.NotEqual(0, hardenedExit); + Assert.Contains("Permission denied", hardenedErr); + + var (plainExit, plainOut, _) = await ReadProbeEnvironAsync(harden: false); + Assert.Equal(0, plainExit); + Assert.Contains("PROBE_MARKER=probe-marker-value", plainOut); + } + + private static async Task<(int Exit, string Stdout, string Stderr)> ReadProbeEnvironAsync(bool harden) + { + var dir = AppContext.BaseDirectory; + var dll = Path.Combine(dir, "Volcanic.AgenticCore.HardeningProbe.dll"); + var psi = new ProcessStartInfo("dotnet") { RedirectStandardInput = true, RedirectStandardOutput = true, UseShellExecute = false }; + if (!File.Exists(Path.Combine(dir, "Volcanic.AgenticCore.HardeningProbe.runtimeconfig.json"))) + { + psi.ArgumentList.Add("exec"); + psi.ArgumentList.Add("--runtimeconfig"); + psi.ArgumentList.Add(Path.Combine(dir, "Volcanic.AgenticCore.Tests.runtimeconfig.json")); + } + psi.ArgumentList.Add(dll); + psi.ArgumentList.Add(harden ? "harden" : "plain"); + psi.Environment["PROBE_MARKER"] = "probe-marker-value"; + + using var probe = Process.Start(psi)!; + try + { + Assert.Equal("ok", await probe.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(30))); + var cat = new ProcessStartInfo("cat") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + cat.ArgumentList.Add($"/proc/{probe.Id}/environ"); + using var reader = Process.Start(cat)!; + var stdoutTask = reader.StandardOutput.ReadToEndAsync(); + var stderrTask = reader.StandardError.ReadToEndAsync(); + await reader.WaitForExitAsync(); + return (reader.ExitCode, (await stdoutTask).Replace('\0', '\n'), await stderrTask); + } + finally + { + probe.StandardInput.Close(); + if (!probe.WaitForExit(10_000)) probe.Kill(); + } + } +} diff --git a/tests/Volcanic.AgenticCore.Tests/Support/NoHostHardening.cs b/tests/Volcanic.AgenticCore.Tests/Support/NoHostHardening.cs new file mode 100644 index 0000000..dba5718 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Support/NoHostHardening.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging.Abstractions; +using Volcanic.AgenticCore.Features.Processes; + +namespace Volcanic.AgenticCore.Tests.Support; + +/// Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable. +internal static class NoHostHardening +{ + [ModuleInitializer] + internal static void Disable() => + HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance); +} diff --git a/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj b/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj index f75015c..c3e10c6 100644 --- a/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj @@ -14,5 +14,6 @@ +