forked from admin/VolcanicAgenticCore
- Fill TimeZoneId only for a configured session/host zone with a resolvable IANA id (IExtensionHost.TimeZoneId); otherwise children inherit the host's TZ instead of TZ=Local/UTC. - Host hardening logs a warning instead of throwing when libc/prctl can't be resolved. - A hook or process_spawn handler returning a null or incomplete context fails the spawn with InvalidOperationException, so bash reports "process environment hook failed". - Move tool-result redaction into the Agent slice with a behavioural no-change check. - ProcessSpawnContext.ApplyTo(psi) sets cwd and replaces the environment; used by bash and ExecAsync. - Split the Telegram process environment types into their own files; move the Extensions.Tests NoHostHardening under Support/; check the Claude Code assembly never references the spawn policy. - Spec and docs: TZ inheritance, incomplete contexts, ApplyTo, async spawn handlers, root skip.
138 lines
5.8 KiB
C#
138 lines
5.8 KiB
C#
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 An_unresolvable_libc_logs_a_warning_and_does_not_throw()
|
|
{
|
|
var logger = new RecordingLogger();
|
|
new HostProcessHardening.Hardener(_ => throw new DllNotFoundException("libc"), isLinux: true, Env()).Apply(On, logger);
|
|
Assert.True(logger.Has(LogLevel.Warning, "Host process hardening failed"));
|
|
Assert.False(logger.Has(LogLevel.Information, "Host process hardened"));
|
|
}
|
|
|
|
[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();
|
|
}
|
|
}
|
|
}
|