forked from admin/VolcanicAgenticCore
feat(telegram): process environment policy, secret values and host hardening
Spec 6 now says this spec adds only the bot token to SecretValues; the speech key and Google client secret are added by the telegram-attachments and google-calendar specs.
This commit is contained in:
@@ -230,7 +230,9 @@ message. The message comes from the exception, never from the environment.
|
||||
|
||||
- `Program.cs` adds, after reading `TelegramOptions` and the sibling options:
|
||||
- `SecretValues`: the bot token, the speech API key, the Google client secret (whichever are set). This catches a
|
||||
secret that arrives under a name no glob matches (for example a Coolify alias).
|
||||
secret that arrives under a name no glob matches (for example a Coolify alias). This spec adds the bot token
|
||||
(`TelegramProcessEnvironment.Configure`); the telegram-attachments and google-calendar specs add their own secret
|
||||
to the same list when they introduce it.
|
||||
- No extra `Deny` names: the defaults already cover every secret the host and the sibling specs read.
|
||||
`TELEGRAM_ALLOWED_USER_IDS` isn't secret and stays.
|
||||
- A `ProcessEnvironment` configuration section binds `Deny` and `Allow` (added to the defaults, not replacing them),
|
||||
|
||||
@@ -32,6 +32,9 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
|
||||
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
|
||||
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
|
||||
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
|
||||
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through to `bash`, scheduled scripts and MCP servers despite the deny-list (e.g. `GH_TOKEN` for `gh`). Added to `ProcessEnvironment:Allow` |
|
||||
| `ProcessEnvironment:Deny` / `ProcessEnvironment:Allow` | built-in deny-list | Added to the defaults (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, their `Section__Key` forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup |
|
||||
| `ProcessEnvironment:HardenHostProcess` | `true` | Linux: the bot process is made non-dumpable. `ProcessEnvironment__HardenHostProcess=false` for a debugging session only |
|
||||
|
||||
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
|
||||
its most recent conversation.
|
||||
@@ -82,6 +85,33 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O
|
||||
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
|
||||
container, and treat the workspace root as writable by anyone on the allow-list.
|
||||
|
||||
Secrets are removed from the environment of `bash`, scheduled scripts and MCP servers: every variable on the deny-list
|
||||
(see `ProcessEnvironment:Deny` above) is dropped, and so is any variable whose value is the bot token, whatever its
|
||||
name (a Coolify alias, for example). `PROCESS_ENV_ALLOW` lets a named variable through, for example `GH_TOKEN` so the
|
||||
agent can use `gh`. At startup the bot logs which rules apply, names only:
|
||||
`Process environment: 15 deny rules, allow: GH_TOKEN`.
|
||||
|
||||
On Linux the bot's own environment can't be read through `/proc/<pid>/environ` or ptrace: at startup the process
|
||||
makes itself non-dumpable (`prctl(PR_SET_DUMPABLE, 0)`). Set `DOTNET_EnableDiagnostics=0` in the bot's environment
|
||||
too (a container image should set it): otherwise the .NET diagnostic socket still hands the environment to any process
|
||||
of the same user, and the bot logs a warning saying so.
|
||||
|
||||
The bot token and the other configured secrets are replaced by `[REDACTED]` in tool output, so the model never sees
|
||||
them in a tool result, whichever file or process they were read from. Encoded forms (base64, URL-encoded, split
|
||||
across lines) aren't matched.
|
||||
|
||||
`ProcessEnvironment:HardenHostProcess=false` (`ProcessEnvironment__HardenHostProcess=false` in the environment),
|
||||
together with unsetting `DOTNET_EnableDiagnostics`, turns the hardening off for a debugging session. While it is on:
|
||||
|
||||
- the bot writes no core dumps, including .NET's own `createdump` on a crash;
|
||||
- `/proc/<pid>/*` of the bot is owned by root, so tools that read it for that pid (such as `lsof -p`) see less;
|
||||
- `dotnet-dump`, `dotnet-trace`, `dotnet-counters` and debuggers can't attach.
|
||||
|
||||
The agent's own processes are unaffected. Turn it back on once you're done.
|
||||
|
||||
This is not a sandbox. The agent still runs as the bot's user and can read the agent dir, session transcripts and any
|
||||
file that user can read. The container is the isolation boundary.
|
||||
|
||||
## Connection loss
|
||||
|
||||
Polling uses Telegram.Bot's built-in loop with a hardened error handler:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram;
|
||||
|
||||
/// <summary>The <c>ProcessEnvironment</c> configuration section. Its lists are added to the core defaults, not replacing them.</summary>
|
||||
public sealed class ProcessEnvironmentSection
|
||||
{
|
||||
public const string SectionName = "ProcessEnvironment";
|
||||
public List<string> Deny { get; set; } = [];
|
||||
public List<string> Allow { get; set; } = [];
|
||||
public bool HardenHostProcess { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>The Telegram host's process environment: section, <c>PROCESS_ENV_ALLOW</c> and the host's secret values.</summary>
|
||||
public static class TelegramProcessEnvironment
|
||||
{
|
||||
public const string AllowVariable = "PROCESS_ENV_ALLOW";
|
||||
|
||||
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c> and the bot token to <paramref name="target"/>.</summary>
|
||||
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(telegram);
|
||||
ArgumentNullException.ThrowIfNull(section);
|
||||
ArgumentNullException.ThrowIfNull(getEnv);
|
||||
target.Deny.AddRange(section.Deny);
|
||||
target.Allow.AddRange(section.Allow);
|
||||
if (getEnv(AllowVariable) is { Length: > 0 } allow)
|
||||
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
target.HardenHostProcess = section.HardenHostProcess;
|
||||
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
|
||||
}
|
||||
|
||||
/// <summary>The startup log line: names only, never values.</summary>
|
||||
public static string Describe(ProcessEnvironmentOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return $"Process environment: {options.Deny.Count} deny rules, allow: {(options.Allow.Count == 0 ? "none" : string.Join(", ", options.Allow))}";
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
@@ -18,7 +19,10 @@ var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get<TelegramOptions>() ?? new TelegramOptions();
|
||||
options.ApplyEnvironment(Environment.GetEnvironmentVariable);
|
||||
var errors = options.Validate();
|
||||
var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>() ?? new ProcessEnvironmentSection();
|
||||
var processEnvironment = new ProcessEnvironmentOptions();
|
||||
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable);
|
||||
var errors = options.Validate().Concat(processEnvironment.Validate()).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine(error);
|
||||
@@ -37,6 +41,7 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
|
||||
builder.Services.AddAgenticCore(o =>
|
||||
{
|
||||
o.DefaultModel = options.Model;
|
||||
o.ProcessEnvironment = processEnvironment;
|
||||
o.AddExtension<ClaudeCodeProviderExtension>();
|
||||
});
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
|
||||
@@ -52,6 +57,9 @@ builder.Services.AddHostedService<BotSetupService>();
|
||||
using var host = builder.Build();
|
||||
|
||||
var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Volcanic.AgenticCore.Telegram");
|
||||
// Before polling and the first claude process; the spawn policy's own call later is a no-op.
|
||||
HostProcessHardening.Apply(processEnvironment, logger);
|
||||
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
|
||||
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
|
||||
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
|
||||
else
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ProcessEnvironmentTests
|
||||
{
|
||||
private const string Token = "123456789:AAH-test-bot-token-value";
|
||||
|
||||
[Fact]
|
||||
public void Configure_adds_the_bot_token_section_lists_and_PROCESS_ENV_ALLOW()
|
||||
{
|
||||
var target = new ProcessEnvironmentOptions();
|
||||
var section = new ProcessEnvironmentSection { Deny = ["EXTRA_*"], Allow = ["NPM_TOKEN"], HardenHostProcess = false };
|
||||
TelegramProcessEnvironment.Configure(target, new TelegramOptions { BotToken = Token }, section,
|
||||
name => name == "PROCESS_ENV_ALLOW" ? "GH_TOKEN, HF_TOKEN" : null);
|
||||
|
||||
Assert.Contains(Token, target.SecretValues);
|
||||
Assert.Equal(ProcessEnvironmentOptions.DefaultDeny.Count + 1, target.Deny.Count);
|
||||
Assert.Contains("EXTRA_*", target.Deny);
|
||||
Assert.Equal(["NPM_TOKEN", "GH_TOKEN", "HF_TOKEN"], target.Allow);
|
||||
Assert.False(target.HardenHostProcess);
|
||||
Assert.Empty(target.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_section_binds_from_configuration()
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ProcessEnvironment:Allow:0"] = "GH_TOKEN",
|
||||
["ProcessEnvironment:HardenHostProcess"] = "false",
|
||||
}).Build();
|
||||
var section = config.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>()!;
|
||||
Assert.Equal(["GH_TOKEN"], section.Allow);
|
||||
Assert.False(section.HardenHostProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_lists_the_rule_count_and_allowed_names_only()
|
||||
{
|
||||
var options = new ProcessEnvironmentOptions();
|
||||
options.Allow.Add("GH_TOKEN");
|
||||
options.SecretValues.Add(Token);
|
||||
var line = TelegramProcessEnvironment.Describe(options);
|
||||
Assert.Equal($"Process environment: {ProcessEnvironmentOptions.DefaultDeny.Count} deny rules, allow: GH_TOKEN", line);
|
||||
Assert.Equal($"Process environment: {ProcessEnvironmentOptions.DefaultDeny.Count} deny rules, allow: none", TelegramProcessEnvironment.Describe(new ProcessEnvironmentOptions()));
|
||||
}
|
||||
|
||||
private static ChatHost Host(IReadOnlyDictionary<string, string?>? environment = null) => new((h, services) =>
|
||||
{
|
||||
h.Options.BotToken = Token;
|
||||
services.Configure<AgenticCoreOptions>(o =>
|
||||
TelegramProcessEnvironment.Configure(o.ProcessEnvironment, h.Options, new ProcessEnvironmentSection { HardenHostProcess = false }, _ => null));
|
||||
if (environment is not null)
|
||||
services.AddSingleton<IProcessSpawnPolicy>(sp =>
|
||||
new ProcessSpawnPolicy(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().Value.ProcessEnvironment, null, () => environment));
|
||||
});
|
||||
|
||||
private static string LastToolResult(ChatHost host) =>
|
||||
JsonSerializer.Serialize(host.Faux.Requests[^1].Messages.OfType<ToolResultMessage>().Last(), VolcanicJson.Options);
|
||||
|
||||
[Fact]
|
||||
public async Task Bash_printing_the_bot_token_from_a_file_shows_REDACTED_to_the_model()
|
||||
{
|
||||
await using var host = Host();
|
||||
var chat = await host.ChatAsync();
|
||||
await File.WriteAllTextAsync(Path.Combine(chat.Session.Cwd, "token.txt"), Token);
|
||||
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = OperatingSystem.IsWindows() ? "type token.txt" : "cat token.txt" }), FauxTurn.Say("done"));
|
||||
|
||||
await host.SendAsync("print the file");
|
||||
await host.SettleAsync();
|
||||
|
||||
var result = LastToolResult(host);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
Assert.DoesNotContain(Token, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Env_in_a_chat_does_not_show_the_bot_token_under_any_name()
|
||||
{
|
||||
var environment = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
foreach (var name in new[] { "PATH", "HOME", "SystemRoot", "ComSpec", "TEMP", "TMP" })
|
||||
if (Environment.GetEnvironmentVariable(name) is { } value) environment[name] = value;
|
||||
environment["TELEGRAM_BOT_TOKEN"] = Token;
|
||||
environment["COOLIFY_BOT_ALIAS"] = Token;
|
||||
await using var host = Host(environment);
|
||||
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = OperatingSystem.IsWindows() ? "set" : "env" }), FauxTurn.Say("done"));
|
||||
|
||||
await host.SendAsync("show the environment");
|
||||
await host.SettleAsync();
|
||||
|
||||
var result = LastToolResult(host);
|
||||
Assert.Contains("PATH=", result, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("TELEGRAM_BOT_TOKEN", result);
|
||||
Assert.DoesNotContain("COOLIFY_BOT_ALIAS", result);
|
||||
Assert.DoesNotContain(Token, result);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
o.DefaultModel = FauxProvider.ModelKey;
|
||||
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
|
||||
o.SessionStoreFactory = _ => store;
|
||||
o.ProcessEnvironment.HardenHostProcess = false;
|
||||
core?.Invoke(o);
|
||||
});
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
Reference in New Issue
Block a user