- 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.
179 lines
8.2 KiB
C#
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>());
|
|
}
|
|
}
|