feat(processes): make the host process non-dumpable on Linux

HostProcessHardening calls prctl(PR_SET_DUMPABLE, 0) through LibraryImport, once per process, from the ProcessSpawnPolicy constructor, and warns when DOTNET_EnableDiagnostics is not 0. Test projects consume the one-shot call with the flag off in a module initializer. A HardeningProbe helper backs a Linux-only /proc integration test.
This commit is contained in:
EugeneTes
2026-09-26 19:09:45 +02:00
parent 0000266ead
commit 76e0f8c0e2
11 changed files with 245 additions and 0 deletions

View File

@@ -17,6 +17,7 @@
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" /> <Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" /> <Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" /> <Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
</Folder> </Folder>

View File

@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Features.Processes;
/// <summary>
/// Linux: makes the host non-dumpable (<c>prctl(PR_SET_DUMPABLE, 0)</c>) so a same-user child can't read
/// <c>/proc/&lt;pid&gt;/environ</c> or ptrace the host (spec §8). Idempotent: the first call wins.
/// </summary>
public static partial class HostProcessHardening
{
private const int PrSetDumpable = 4;
private static readonly Hardener Shared = new(SetDumpable, OperatingSystem.IsLinux(), Environment.GetEnvironmentVariable);
/// <summary>Applies the hardening once per process; later calls are no-ops whatever their options say.</summary>
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();
/// <summary>The testable core: <c>setDumpable</c> returns 0 on success, else the errno.</summary>
internal sealed class Hardener(Func<int, int> setDumpable, bool isLinux, Func<string, string?> 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");
}
}
}

View File

@@ -27,6 +27,7 @@ public sealed class ProcessSpawnPolicy : IProcessSpawnPolicy
_environment = environment ?? CurrentEnvironment; _environment = environment ?? CurrentEnvironment;
_isWindows = isWindows; _isWindows = isWindows;
_filter = new EnvironmentFilter(options); _filter = new EnvironmentFilter(options);
HostProcessHardening.Apply(options, _logger);
} }
public ProcessSpawnContext Prepare(ProcessSpawnRequest request) public ProcessSpawnContext Prepare(ProcessSpawnRequest request)

View File

@@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<Description>Headless, extensible agent runtime (pi clone) organised as vertical slices.</Description> <Description>Headless, extensible agent runtime (pi clone) organised as vertical slices.</Description>
<!-- LibraryImport (prctl in HostProcessHardening) needs it; SYSLIB1062 otherwise. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" /> <ProjectReference Include="../Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />

View File

@@ -0,0 +1,13 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Extensions.Tests;
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
internal static class NoHostHardening
{
[ModuleInitializer]
internal static void Disable() =>
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
}

View File

@@ -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/<pid>/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;

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<Description>Helper for HostProcessHardeningTests: applies hardening, prints ok, waits for stdin to close.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
internal static class NoHostHardening
{
[ModuleInitializer]
internal static void Disable() =>
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
}

View File

@@ -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<string, string?> 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<int>();
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();
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Tests.Support;
/// <summary>Consumes the process-wide hardening call with the flag off, so the test runner stays debuggable.</summary>
internal static class NoHostHardening
{
[ModuleInitializer]
internal static void Disable() =>
HostProcessHardening.Apply(new ProcessEnvironmentOptions { HardenHostProcess = false }, NullLogger.Instance);
}

View File

@@ -14,5 +14,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" /> <ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" /> <ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
<ProjectReference Include="../Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>