Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Tests/Processes/ProcessSpawnPolicyTests.cs
EugeneTes 76bda6cf55 fix(processes): address review findings
- 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.
2026-09-26 19:32:47 +02:00

179 lines
8.2 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Processes;
public class ProcessSpawnPolicyTests
{
private static ProcessSpawnRequest Request(IReadOnlyDictionary<string, string?>? env = null, string? tz = null) => new()
{
Purpose = ProcessPurpose.BashTool, Command = "env", Cwd = "/work", Environment = env, TimeZoneId = tz,
Scope = "telegram-1", SessionId = "s1", Origin = "test",
};
private static IReadOnlyDictionary<string, string> Prepare(Dictionary<string, string?> inherited, Action<ProcessEnvironmentOptions>? configure = null, ProcessSpawnRequest? request = null) =>
TestProcesses.Policy(inherited, configure).Prepare(request ?? Request()).Environment;
[Fact]
public void Default_deny_removes_secret_shaped_names_and_keeps_ordinary_ones()
{
var env = Prepare(new()
{
["OPENAI_API_KEY"] = "a", ["Telegram__Speech__ApiKey"] = "b", ["Telegram__BotToken"] = "c",
["GoogleCalendar__ClientSecret"] = "d", ["DB_PASSWORD"] = "e", ["TELEGRAM_BOT_TOKEN"] = "f",
["GOOGLE_CLIENT_SECRET"] = "g", ["ANTHROPIC_AUTH_TOKEN"] = "h", ["CLAUDE_CODE_OAUTH_TOKEN"] = "i",
["PATH"] = "/usr/bin", ["HOME"] = "/home/bot", ["LANG"] = "C.UTF-8",
["PLAYWRIGHT_BROWSERS_PATH"] = "/ms-playwright", ["TOKENIZERS_PARALLELISM"] = "false",
});
Assert.Equal(["HOME", "LANG", "PATH", "PLAYWRIGHT_BROWSERS_PATH", "TOKENIZERS_PARALLELISM"], env.Keys.Order(StringComparer.Ordinal));
}
[Fact]
public void Allow_keeps_a_glob_denied_name()
{
var env = Prepare(new() { ["GITHUB_TOKEN"] = "ghp_1" }, o => o.Allow.Add("GITHUB_TOKEN"));
Assert.Equal("ghp_1", env["GITHUB_TOKEN"]);
}
[Fact]
public void Allow_glob_does_not_readmit_an_explicitly_denied_name()
{
var env = Prepare(new() { ["TELEGRAM_BOT_TOKEN"] = "t", ["GITHUB_TOKEN"] = "g" }, o => o.Allow.Add("*_TOKEN"));
Assert.False(env.ContainsKey("TELEGRAM_BOT_TOKEN"));
Assert.Equal("g", env["GITHUB_TOKEN"]);
}
[Fact]
public void Allow_of_an_explicitly_denied_name_fails_validation()
{
var options = TestProcesses.Options(o => o.Allow.Add("telegram_bot_token"));
var error = Assert.Single(options.Validate());
Assert.Contains("TELEGRAM_BOT_TOKEN", error, StringComparison.OrdinalIgnoreCase);
Assert.Throws<InvalidOperationException>(() => new ProcessSpawnPolicy(options));
}
[Fact]
public void Secret_value_under_an_innocent_name_is_removed()
{
var env = Prepare(new() { ["COOLIFY_ALIAS"] = "123456:super-secret", ["PATH"] = "/bin" }, o => o.SecretValues.Add("123456:super-secret"));
Assert.False(env.ContainsKey("COOLIFY_ALIAS"));
Assert.True(env.ContainsKey("PATH"));
}
[Fact]
public void Secret_values_shorter_than_8_characters_are_ignored()
{
var env = Prepare(new() { ["ENABLED"] = "true", ["PORT"] = "8080" }, o => { o.SecretValues.Add("true"); o.SecretValues.Add("8080"); });
Assert.Equal("true", env["ENABLED"]);
Assert.Equal("8080", env["PORT"]);
}
[Fact]
public void Explicit_environment_beats_the_filter_and_null_removes()
{
var env = Prepare(new() { ["PATH"] = "/bin", ["DROP_ME"] = "x" }, request: Request(new Dictionary<string, string?>
{
["GITHUB_TOKEN"] = "explicit", ["DROP_ME"] = null,
}));
Assert.Equal("explicit", env["GITHUB_TOKEN"]);
Assert.False(env.ContainsKey("DROP_ME"));
}
[Fact]
public void TimeZoneId_sets_TZ_unless_explicit_environment_sets_it()
{
var policy = new ProcessSpawnPolicy(TestProcesses.Options(), null, () => new Dictionary<string, string?> { ["TZ"] = "UTC" }, isWindows: false);
Assert.Equal("Asia/Tokyo", policy.Prepare(Request(tz: "Asia/Tokyo")).Environment["TZ"]);
Assert.Equal("Europe/Berlin", policy.Prepare(Request(new Dictionary<string, string?> { ["TZ"] = "Europe/Berlin" }, "Asia/Tokyo")).Environment["TZ"]);
}
[Fact]
public void TimeZoneId_is_not_applied_on_windows()
{
var policy = new ProcessSpawnPolicy(TestProcesses.Options(), null, () => new Dictionary<string, string?>(), isWindows: true);
Assert.False(policy.Prepare(Request(tz: "Asia/Tokyo")).Environment.ContainsKey("TZ"));
}
[Fact]
public void Hooks_run_in_order_and_can_rewrite_command_cwd_and_env()
{
var policy = TestProcesses.Policy(new Dictionary<string, string?> { ["PATH"] = "/bin" }, o =>
{
o.Hooks.Add(c => c with { Command = "source ~/.profile\n" + c.Command, Environment = new Dictionary<string, string>(c.Environment) { ["ORDER"] = "1" } });
o.Hooks.Add(c => c with { Cwd = "/elsewhere", Environment = new Dictionary<string, string>(c.Environment) { ["ORDER"] = c.Environment["ORDER"] + ",2" } });
});
var result = policy.Prepare(Request());
Assert.Equal("source ~/.profile\nenv", result.Command);
Assert.Equal("/elsewhere", result.Cwd);
Assert.Equal("1,2", result.Environment["ORDER"]);
}
[Fact]
public void Hooks_cannot_change_purpose_scope_session_or_origin()
{
var policy = TestProcesses.Policy(new Dictionary<string, string?>(), o =>
o.Hooks.Add(c => c with { Purpose = ProcessPurpose.Other, Scope = "other", SessionId = "other", Origin = "other" }));
var result = policy.Prepare(Request());
Assert.Equal(ProcessPurpose.BashTool, result.Purpose);
Assert.Equal("telegram-1", result.Scope);
Assert.Equal("s1", result.SessionId);
Assert.Equal("test", result.Origin);
}
[Fact]
public void A_throwing_hook_throws_out_of_Prepare()
{
var policy = TestProcesses.Policy(new Dictionary<string, string?>(), o => o.Hooks.Add(_ => throw new InvalidOperationException("hook broke")));
var ex = Assert.Throws<InvalidOperationException>(() => policy.Prepare(Request()));
Assert.Equal("hook broke", ex.Message);
}
[Theory]
[InlineData("null")]
[InlineData("environment")]
[InlineData("cwd")]
public void A_hook_that_returns_an_incomplete_context_throws_out_of_Prepare(string broken)
{
var policy = TestProcesses.Policy(new Dictionary<string, string?>(), o => o.Hooks.Add(c => broken switch
{
"null" => null!,
"environment" => c with { Environment = null! },
_ => c with { Cwd = null! },
}));
var ex = Assert.Throws<InvalidOperationException>(() => policy.Prepare(Request()));
Assert.Contains("process spawn hook", ex.Message);
}
[Fact]
public void Removed_names_are_logged_and_values_never_are()
{
var logger = new RecordingLogger();
var policy = TestProcesses.Policy(new Dictionary<string, string?> { ["OPENAI_API_KEY"] = "sk-live-abcdef123456", ["ALIAS"] = "value-secret-99" },
o => o.SecretValues.Add("value-secret-99"), logger);
policy.Prepare(Request());
Assert.True(logger.Has(LogLevel.Debug, "OPENAI_API_KEY"));
Assert.True(logger.Has(LogLevel.Debug, "ALIAS"));
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("sk-live-abcdef123456") || e.Message.Contains("value-secret-99"));
}
[Fact]
public void AddAgenticCore_registers_the_policy_and_a_host_replacement_wins()
{
var services = new ServiceCollection();
services.AddAgenticCore(o => o.ProcessEnvironment.HardenHostProcess = false);
using (var provider = services.BuildServiceProvider())
Assert.IsType<ProcessSpawnPolicy>(provider.GetRequiredService<IProcessSpawnPolicy>());
var custom = new ServiceCollection();
var replacement = TestProcesses.Policy(new Dictionary<string, string?>());
custom.AddSingleton<IProcessSpawnPolicy>(replacement);
custom.AddAgenticCore();
using var customProvider = custom.BuildServiceProvider();
Assert.Same(replacement, customProvider.GetRequiredService<IProcessSpawnPolicy>());
}
}