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 @@
+