feat(telegram): pass GOOGLE_CLIENT_SECRET to agent processes, still redacted

GOOGLE_CLIENT_SECRET is no longer denied by name in the core defaults (the
*_SECRET glob still removes it); the Telegram host allows it by default so
workspace skills (gmail, gdrive) can use the Google OAuth client. Its value
stays in SecretValues: redacted from tool output, and aliases with the same
value are still removed.
This commit is contained in:
EugeneTes
2026-10-12 15:10:49 +00:00
parent bb1c534a7a
commit 0cae6f600a
8 changed files with 93 additions and 15 deletions

View File

@@ -81,7 +81,7 @@ The compose file maps them into the container.
| `OPENROUTER_API_KEY` | — | Turns on group watching with topic triggers, classified by Jev through OpenRouter ([telegram-bot.md](telegram-bot.md#groups-and-triggers)) | No, and redacted |
| `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes |
| `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes |
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
| `GOOGLE_CLIENT_SECRET` | — | See above. Passed to the agent's processes (gmail/gdrive skills), its value redacted from tool output | Yes, and redacted |
| `MCP_ENCRYPTION_SECRET` | — | Seals each chat's remote MCP keys; without it the bot token does ([mcp.md](mcp.md#remote-servers-per-chat)) | No, and redacted |
| `A2A_ENABLED` | `false` | Serves other agents over A2A on port 8080 ([a2a.md](a2a.md), [telegram-bot.md#a2a](telegram-bot.md#a2a)); passed as `A2A__Enabled`. Needs a domain in Coolify (see below) | No |
| `A2A_PUBLIC_URL` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`; passed as `A2A__PublicUrl`. Required for push notifications to work | No |
@@ -110,15 +110,17 @@ MCP servers:
- anything matching the default deny rules: `*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their
`Section__Key` forms (`*__APIKEY`, `*__*TOKEN`, `*__*SECRET`, `*__*PASSWORD`), plus `TELEGRAM_BOT_TOKEN`,
`OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`;
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`;
- any variable whose value is the bot token, the speech or OpenRouter API key or the Google client secret, whatever its name.
Those values are also replaced by `[REDACTED]` in tool output. The startup log names the rules, never values:
`Process environment: 15 deny rules, allow: none`.
The one exception is `GOOGLE_CLIENT_SECRET` itself: the bot always allows it, so skills such as gmail and gdrive can
use the OAuth client. Its value is still replaced by `[REDACTED]` in tool output, like all the values above. The
startup log names the rules, never values: `Process environment: 14 deny rules, allow: GOOGLE_CLIENT_SECRET`.
To give the agent a credential on purpose, add the variable in Coolify, add it to `environment:` in the compose file,
and list its name in `PROCESS_ENV_ALLOW` (`PROCESS_ENV_ALLOW=GH_TOKEN` lets `gh` work). A name that is on the deny
list by name, such as `TELEGRAM_BOT_TOKEN`, can't be allowed: trying stops startup.
list by name, such as `TELEGRAM_BOT_TOKEN`, can't be allowed: trying stops startup. Listing `GOOGLE_CLIENT_SECRET`
there is harmless.
### Other settings

View File

@@ -62,8 +62,9 @@ dotnet run --project hosts/Volcanic.AgenticCore.Telegram
`/help`.
The client secret is added to the process policy's `SecretValues`, so it shows up as `[REDACTED]` in tool output.
`GOOGLE_CLIENT_SECRET` is also on the default deny-list (`*_SECRET`), so `bash`, scheduled scripts and MCP servers
never see it in their environment. See [telegram-bot.md](telegram-bot.md#security).
The core's default deny-list (`*_SECRET`) would keep `GOOGLE_CLIENT_SECRET` out of `bash`, scheduled scripts and MCP
servers, but the Telegram host allows it by default, so skills (gmail, gdrive) can read it from the environment. Its
value is still redacted from tool output. See [telegram-bot.md](telegram-bot.md#security).
## Connecting a chat

View File

@@ -149,12 +149,17 @@ public sealed class ProcessEnvironmentOptions
| `*_TOKEN`, `*__*TOKEN` | `TELEGRAM_BOT_TOKEN`, `GITHUB_TOKEN`, `Telegram__BotToken` |
| `*_SECRET`, `*__*SECRET` | `GOOGLE_CLIENT_SECRET`, `GoogleCalendar__ClientSecret` |
| `*_PASSWORD`, `*__*PASSWORD` | database and registry passwords |
| `TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` | named explicitly so that an `Allow` glob never re-admits them by accident (an explicit name in `Deny` beats a glob in `Allow`) |
| `TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY` | named explicitly so that an `Allow` glob never re-admits them by accident (an explicit name in `Deny` beats a glob in `Allow`) |
| `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN` | the provider credentials (§7) |
The `__` forms exist because .NET configuration reads `Section__Key` variables, and the Docker spec tells operators to
use them (`Telegram__Speech__ApiKey`, `GoogleCalendar__ClientSecret`).
Amendment (2026-10-12): `GOOGLE_CLIENT_SECRET` is no longer denied by name. The core still removes it through
`*_SECRET`; the Telegram host adds it to `Allow` by default so workspace skills (gmail, gdrive) can use the Google OAuth
client. Its value stays in `SecretValues`, so it is redacted from tool output, and an alias holding the same value is
still removed.
Precedence: an explicit name in `Deny` removes; else a name or glob in `Allow` keeps; else a glob in `Deny` removes;
else a `SecretValues` match removes; else kept. `Allow` of an explicitly denied name is a startup validation error, so
the conflict is visible instead of silently lost.

View File

@@ -61,7 +61,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `Telegram:Triggers:MaxQueuedPerTrigger` | `5000` | Messages kept per snoozed trigger; past it the oldest are dropped |
| `Telegram:InlineImageMaxBytes` | `3750000` | Image files up to this size are also shown to the model; must be positive |
| `GOOGLE_CLIENT_ID` / `GoogleCalendar:ClientId` | — | Optional. With the secret, every chat gets the `calendar` tool and the `/calendar_*` commands; see [google-calendar.md](google-calendar.md) |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; redacted from tool output and hidden from `bash` |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; `GOOGLE_CLIENT_SECRET` is passed to `bash`, scheduled scripts and MCP servers (for skills), its value redacted from tool output |
| `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 |
@@ -401,8 +401,9 @@ token.
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`.
agent can use `gh`. `GOOGLE_CLIENT_SECRET` is always allowed, so skills such as gmail and gdrive can use the Google
OAuth client; its value stays among the redacted secrets. At startup the bot logs which rules apply, names only:
`Process environment: 14 deny rules, allow: GH_TOKEN, GOOGLE_CLIENT_SECRET`.
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

View File

@@ -8,7 +8,10 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token, the speech and OpenRouter API keys and the Google client secret to <paramref name="target"/>.</summary>
/// <summary>Let through by default: skills (gmail, gdrive) need it. Its value stays in the secret values, so tool output shows <c>[REDACTED]</c>.</summary>
public const string GoogleClientSecretVariable = "GOOGLE_CLIENT_SECRET";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, <c>GOOGLE_CLIENT_SECRET</c> to the allow-list, and the bot token, the speech and OpenRouter API keys and the Google client secret to the secret values of <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv,
GoogleCalendarOptions? calendar = null)
{
@@ -20,6 +23,7 @@ public static class TelegramProcessEnvironment
target.Allow.AddRange(section.Allow);
if (getEnv(AllowVariable) is { Length: > 0 } allow)
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
if (!target.Allow.Contains(GoogleClientSecretVariable, StringComparer.OrdinalIgnoreCase)) target.Allow.Add(GoogleClientSecretVariable);
target.HardenHostProcess = section.HardenHostProcess;
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
if (!string.IsNullOrWhiteSpace(telegram.Speech?.ApiKey)) target.SecretValues.Add(telegram.Speech.ApiKey);

View File

@@ -15,7 +15,7 @@ public sealed class ProcessEnvironmentOptions
"*_TOKEN", "*__*TOKEN",
"*_SECRET", "*__*SECRET",
"*_PASSWORD", "*__*PASSWORD",
"TELEGRAM_BOT_TOKEN", "OPENAI_API_KEY", "GOOGLE_CLIENT_SECRET",
"TELEGRAM_BOT_TOKEN", "OPENAI_API_KEY",
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
];

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -15,6 +16,7 @@ namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ProcessEnvironmentTests
{
private const string Token = "123456789:AAH-test-bot-token-value";
private const string GoogleSecret = "GOCSPX-test-google-client-secret";
[Fact]
public void Configure_adds_the_bot_token_section_lists_and_PROCESS_ENV_ALLOW()
@@ -27,11 +29,32 @@ public sealed class ProcessEnvironmentTests
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.Equal(["NPM_TOKEN", "GH_TOKEN", "HF_TOKEN", "GOOGLE_CLIENT_SECRET"], target.Allow);
Assert.False(target.HardenHostProcess);
Assert.Empty(target.Validate());
}
[Fact]
public void GOOGLE_CLIENT_SECRET_in_PROCESS_ENV_ALLOW_is_valid_and_not_duplicated()
{
var target = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(target, new TelegramOptions { BotToken = Token }, new ProcessEnvironmentSection(),
name => name == "PROCESS_ENV_ALLOW" ? "google_client_secret" : null);
Assert.Equal(["google_client_secret"], target.Allow);
Assert.Empty(target.Validate());
_ = new ProcessSpawnPolicy(target, null, () => new Dictionary<string, string?>());
}
[Fact]
public void Configure_adds_the_google_client_secret_to_the_secret_values()
{
var target = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(target, new TelegramOptions { BotToken = Token }, new ProcessEnvironmentSection(), _ => null,
new GoogleCalendarOptions { ClientSecret = GoogleSecret });
Assert.Contains(GoogleSecret, target.SecretValues);
}
[Fact]
public void Configure_adds_the_speech_api_key_to_the_secret_values()
{
@@ -70,7 +93,8 @@ public sealed class ProcessEnvironmentTests
{
h.Options.BotToken = Token;
services.Configure<AgenticCoreOptions>(o =>
TelegramProcessEnvironment.Configure(o.ProcessEnvironment, h.Options, new ProcessEnvironmentSection { HardenHostProcess = false }, _ => null));
TelegramProcessEnvironment.Configure(o.ProcessEnvironment, h.Options, new ProcessEnvironmentSection { HardenHostProcess = false }, _ => null,
new GoogleCalendarOptions { ClientSecret = GoogleSecret }));
if (environment is not null)
services.AddSingleton<IProcessSpawnPolicy>(sp =>
new ProcessSpawnPolicy(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().Value.ProcessEnvironment, null, () => environment));
@@ -115,4 +139,34 @@ public sealed class ProcessEnvironmentTests
Assert.DoesNotContain("COOLIFY_BOT_ALIAS", result);
Assert.DoesNotContain(Token, result);
}
[Fact]
public async Task Bash_sees_GOOGLE_CLIENT_SECRET_but_the_model_sees_REDACTED_and_other_secrets_stay_out()
{
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["GOOGLE_CLIENT_SECRET"] = GoogleSecret;
environment["GOOGLE_ALIAS_COPY"] = GoogleSecret;
environment["TELEGRAM_BOT_TOKEN"] = Token;
environment["OPENAI_API_KEY"] = "sk-openai-test-key-123";
environment["OTHER_SERVICE_SECRET"] = "other-service-secret-456";
await using var host = Host(environment);
var command = OperatingSystem.IsWindows()
? "if defined GOOGLE_CLIENT_SECRET (echo HAS_GOOGLE_SECRET) & set"
: "[ \"$GOOGLE_CLIENT_SECRET\" = '" + GoogleSecret + "' ] && echo HAS_GOOGLE_SECRET; env";
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command }), FauxTurn.Say("done"));
await host.SendAsync("show the environment");
await host.SettleAsync();
var result = LastToolResult(host);
Assert.Contains("HAS_GOOGLE_SECRET", result);
Assert.Contains("GOOGLE_CLIENT_SECRET=[REDACTED]", result);
Assert.DoesNotContain(GoogleSecret, result);
Assert.DoesNotContain("GOOGLE_ALIAS_COPY", result);
Assert.DoesNotContain("TELEGRAM_BOT_TOKEN", result);
Assert.DoesNotContain("OPENAI_API_KEY", result);
Assert.DoesNotContain("OTHER_SERVICE_SECRET", result);
}
}

View File

@@ -56,6 +56,17 @@ public class ProcessSpawnPolicyTests
Assert.Throws<InvalidOperationException>(() => new ProcessSpawnPolicy(options));
}
[Fact]
public void Allowed_GOOGLE_CLIENT_SECRET_is_valid_and_kept_even_when_its_value_is_a_secret_value()
{
var options = TestProcesses.Options(o => { o.Allow.Add("GOOGLE_CLIENT_SECRET"); o.SecretValues.Add("GOCSPX-google-secret"); });
Assert.Empty(options.Validate());
var env = Prepare(new() { ["GOOGLE_CLIENT_SECRET"] = "GOCSPX-google-secret", ["ALIAS"] = "GOCSPX-google-secret" },
o => { o.Allow.Add("GOOGLE_CLIENT_SECRET"); o.SecretValues.Add("GOCSPX-google-secret"); });
Assert.Equal("GOCSPX-google-secret", env["GOOGLE_CLIENT_SECRET"]);
Assert.False(env.ContainsKey("ALIAS"));
}
[Fact]
public void Secret_value_under_an_innocent_name_is_removed()
{