Files
VolcanicAgenticCore/docs/superpowers/specs/2026-09-26-process-environment-hook-design.md

33 KiB

Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design

Date: 2026-09-26 Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md, the scheduler ScriptRunner with 2026-09-26-scheduler-design.md) Used by: 2026-09-26-scheduler-design.md (watcher scripts), 2026-09-26-mcp-client-playwright-design.md (MCP server processes), 2026-09-26-telegram-attachments-design.md (OPENAI_API_KEY), 2026-09-26-google-calendar-design.md (GOOGLE_CLIENT_SECRET), 2026-09-26-docker-deployment-design.md (the secrets set in Coolify).

Problem

Every process the agent starts inherits the host's full environment. BashTool (src/Volcanic.AgenticCore/Features/Tools/BuiltIn/BashTool.cs) builds a ProcessStartInfo and never touches Environment, so env in any chat prints TELEGRAM_BOT_TOKEN, OPENAI_API_KEY, GOOGLE_CLIENT_SECRET and whatever else the deployment sets. The sibling specs add more spawners with the same flaw or their own partial fix: the scheduler's watcher scripts scrub a private list (SchedulerOptions.ScrubbedEnvironment, default only TELEGRAM_BOT_TOKEN), and MCP server processes (Playwright) inherit everything. IExtensionApi.ExecAsync inherits everything too.

A secret in a child's environment ends up in tool output, in the transcript sent to the model provider, in scripts the agent writes, and in any third-party package (npx, pip) that reads its environment. A prompt-injected page only has to ask for env.

We want one place that decides what every agent-spawned process sees, reachable from extensions that reference only Abstractions (the scheduler and MCP are extensions), with a safe default.

Two gaps remain once the children's environment is clean. The agent's bash runs as the host's OS user, so it can read the host's own environment from /proc/<host pid>/environ (or attach to the host with ptrace). And a secret that reaches tool output some other way (read from a file, echoed by a misbehaving MCP server) goes straight into the transcript and to the model provider. §8 closes the first on Linux, §9 the second for the secrets the host knows.

Reference: pi's bash spawn hook

pi (packages/coding-agent/src/core/tools/bash.ts) builds a BashSpawnContext { command, cwd, env } for every bash call (the shell environment minus its own PI_* variables, plus session metadata) and passes it through an optional synchronous spawnHook: (ctx) => ctx given to createBashTool(cwd, { spawnHook }). The hook may rewrite the command (e.g. source ~/.profile\n<command>), the cwd and the env. An extension that wants a hook re-registers bash with one (examples/extensions/bash-spawn-hook.ts). pi has no deny-list: the default hook is none.

We keep the shape (a context of command, cwd and env, and a pure function that returns a new one) and generalise it: it covers every process spawned on the agent's behalf, not only bash; it is configured once on the host instead of per tool instance; and it has a default deny-list.

Decisions

  • One policy for every agent-requested process. The bash tool, scheduler watcher scripts, MCP server processes (discovery and per-session) and IExtensionApi.ExecAsync all build their child environment through IProcessSpawnPolicy. Nothing spawns with the inherited environment untouched.
  • The deny-list filters what is inherited, never what is passed explicitly. Inherited variables matching the deny-list are removed. Variables the caller sets on purpose (an MCP server's env entry, ExecOptions.Environment, TZ for the chat's zone) are applied after the filter and are not filtered. So an MCP server that needs GITHUB_TOKEN gets it when its config names it, and no other process does. This one rule answers every "but this process legitimately needs X" case.
  • A default deny-list, an allow-override, and secret values. AgenticCoreOptions.ProcessEnvironment carries Deny (names and globs, pre-filled), Allow (names and globs that win over Deny) and SecretValues (any inherited variable whose value equals one of them is removed, whatever its name). Hosts add their own secrets to both.
  • Host hooks, then extension handlers. Host code adds ProcessSpawnHooks (pi's spawnHook, host-wide). Inside a session, extensions get a mutable ProcessSpawnEvent after the host hooks. Extensions are trusted in-process code that can already read Environment directly, so letting them run last gives nothing away, and it matches the existing extension-event model (mutable args, load order).
  • The contract lives in Abstractions. IProcessSpawnPolicy, ProcessSpawnRequest, ProcessSpawnContext, ProcessSpawnHook, ProcessSpawnEvent and ISecretRedactor (§9) are in Volcanic.AgenticCore.Abstractions (Processes/). The policy is a DI singleton (for process-wide spawners like JobScheduler) and is also reachable from extensions through IExtensionApi.Processes and, session-bound, IExtensionContext.PrepareProcessAsync.
  • Fail closed. A hook or handler that throws fails that spawn. Nothing falls back to the unfiltered environment.
  • The Claude Code provider is not covered. claude -p, claude auth login, claude auth status and the doctor's probes are provider processes, not agent-requested ones, and they already get a replaced environment (see §7).
  • The host process is made non-dumpable on Linux (§8). At startup the host calls prctl(PR_SET_DUMPABLE, 0), so its /proc/<pid>/* entries become root-owned and a same-uid child (the agent's bash, a watcher, an MCP server) can neither read /proc/<host pid>/environ nor ptrace the host. The .NET diagnostic IPC socket, which would otherwise hand out the same environment, is disabled with it. On by default, with an opt-out for debugging.
  • Known secret values are redacted from tool output (§9). Every SecretValues entry is replaced by [REDACTED] in tool results, streamed progress updates and tool error messages before the model, the transcript, hosts or extensions see them. That covers bash, MCP tools and every other tool in one place, the agent loop.
  • Still not a sandbox. The agent still runs as the bot's OS user, so it can read config files, everything under the agent dir (session transcripts, chat-settings.json, jobs.json, the Claude Code login and the calendar token files other specs keep outside the workspace) and anything else that user can read. The hook, the hardening and the redaction stop the accidental and the casually injected leak (env, cat /proc/<pid>/environ, a script that logs its environment, a package that phones home with it). They don't stop a determined agent that goes after files on disk or encodes a secret it found before printing it. The container is the isolation boundary.

Components

1. Contract (Abstractions, Processes/ProcessSpawn.cs)

public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other }

/// <summary>What a spawner asks for. The policy turns it into the complete child context.</summary>
public sealed record ProcessSpawnRequest
{
    public required ProcessPurpose Purpose { get; init; }
    /// <summary>A shell command line (BashTool, ScheduledScript) or an executable (McpServer, ExtensionExec).</summary>
    public required string Command { get; init; }
    public IReadOnlyList<string> Arguments { get; init; } = [];
    public required string Cwd { get; init; }
    /// <summary>Set or removed (null) after the deny-list; never filtered.</summary>
    public IReadOnlyDictionary<string, string?>? Environment { get; init; }
    /// <summary>IANA id; sets TZ unless <see cref="Environment"/> sets it.</summary>
    public string? TimeZoneId { get; init; }
    /// <summary>Session scope or scheduler target, for hooks that differ per chat.</summary>
    public string? Scope { get; init; }
    public string? SessionId { get; init; }
    /// <summary>Who asked: the extension, MCP server or job id. Informational.</summary>
    public string? Origin { get; init; }
}

/// <summary>pi's BashSpawnContext, generalised: the complete command, cwd and environment the child gets.</summary>
public sealed record ProcessSpawnContext(
    ProcessPurpose Purpose, string Command, IReadOnlyList<string> Arguments, string Cwd,
    IReadOnlyDictionary<string, string> Environment, string? Scope, string? SessionId, string? Origin);

public delegate ProcessSpawnContext ProcessSpawnHook(ProcessSpawnContext context);

public interface IProcessSpawnPolicy
{
    /// <summary>Inherit, filter, apply the explicit variables and TZ, run the host hooks. No extension events.</summary>
    ProcessSpawnContext Prepare(ProcessSpawnRequest request);
}
  • ProcessSpawnContext.Environment is the complete environment: spawners clear ProcessStartInfo.Environment and copy it in, through ProcessSpawnContextExtensions.ApplyTo(psi) (Abstractions), which also sets the working directory. Names compare ordinally on Unix and case-insensitively on Windows, as the OS does.
  • Hooks are synchronous and pure, as in pi. Anything needing I/O belongs in the spawner. A hook that returns null, or a context without command, arguments, cwd or environment, fails the spawn like a throwing hook.

2. Configuration — AgenticCoreOptions.ProcessEnvironment

public sealed class ProcessEnvironmentOptions
{
    /// <summary>Names or globs (<c>*</c>, case-insensitive) removed from the inherited environment.</summary>
    public List<string> Deny { get; } = [.. DefaultDeny];
    /// <summary>Names or globs kept even when <see cref="Deny"/> matches.</summary>
    public List<string> Allow { get; } = [];
    /// <summary>Inherited variables with one of these values are removed, whatever their name. Values under 8 characters are ignored.</summary>
    public List<string> SecretValues { get; } = [];
    /// <summary>Run in order after the filter (pi's spawnHook, host-wide).</summary>
    public List<ProcessSpawnHook> Hooks { get; } = [];
}

DefaultDeny:

Entry Catches
*_API_KEY, *_APIKEY, *__APIKEY OPENAI_API_KEY, Telegram__Speech__ApiKey
*_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)
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).

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. Names and globs in Deny and Allow both match case-insensitively (an explicit name as well as a glob). ProcessEnvironmentOptions.Validate() returns the configuration errors (empty entries, an explicitly denied name that is also allowed); DefaultDeny and MinimumSecretLength (8) are public on the options class.

3. The policy — ProcessSpawnPolicy (core, new slice Features/Processes)

Prepare(request):

  1. Snapshot the current process environment.
  2. Remove the entries §2 rejects. Record removed names (never values) for the debug log.
  3. Apply request.Environment: set non-null values, remove null ones. Not filtered.
  4. If request.TimeZoneId is set and step 3 didn't set TZ, set TZ to it. The Docker image has tzdata, so an IANA id works for date, Python and Node. On Windows TZ isn't used by the OS and the step is skipped.
  5. Run Hooks in order. A hook may change Command, Arguments, Cwd and Environment; Purpose, Scope, SessionId and Origin are copied back from the request whatever it returns. A hook that returns null fails the spawn like one that throws.
  6. Log at Debug: purpose, origin, scope and the removed names.

AddAgenticCore registers it with TryAddSingleton<IProcessSpawnPolicy> (a factory over the bound options), so a host can replace it wholesale. AgenticCoreOptions.ProcessEnvironment is validated when the service is built: the ProcessSpawnPolicy constructor throws InvalidOperationException when ProcessEnvironmentOptions.Validate() reports errors.

4. Session binding — IExtensionContext.PrepareProcessAsync, IExtensionApi.Processes, ProcessSpawnEvent

// IExtensionApi (available during activation, not session-bound)
IProcessSpawnPolicy Processes { get; }

// IExtensionContext (session-bound)
ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken);

public sealed class ProcessSpawnEvent(ProcessSpawnContext context) : ExtensionEvent
{
    public override string Name => "process_spawn";
    public ProcessSpawnContext Context { get; set; } = context;
}

PrepareProcessAsync fills Scope, SessionId and (unless given) TimeZoneId from the session (the session's time zone comes from AgentSessionOptions.TimeZone, else AgenticCoreOptions.DefaultTimeZone, scheduler spec §8), calls Processes.Prepare, then emits ProcessSpawnEvent to extensions in load order. When neither option sets a zone, or the zone has no IANA id that resolves to a system zone, TimeZoneId stays null and the child inherits the host's TZ: the machine's local zone may be unnamed (.NET calls an unmatched /etc/localtime "Local") or come from a POSIX rule string, and overwriting TZ with that would move the child to UTC. Each handler may replace Context; a handler that leaves it null or without command, arguments, cwd or environment fails the spawn with InvalidOperationException. There's no Block: refusing a command is ToolCallEvent.Block's job, before any process exists.

IExtensionApi.Processes, IExtensionApi.Redactor and IExtensionContext.PrepareProcessAsync are default interface members that throw NotSupportedException, so implementations outside the core (test fakes, other front ends) keep compiling; the core's ExtensionApi and ExtensionContext implement them. A ProcessSpawnEvent handler that throws is reported as extension_error and the exception propagates out of PrepareProcessAsync, failing the spawn.

The session time zone members it reads (AgentSessionOptions.TimeZone, AgenticCoreOptions.DefaultTimeZone, AgentSession.TimeZone, IExtensionContext.TimeZone) are introduced with this spec, ahead of the rest of scheduler spec §8. IExtensionContext.TimeZone is a default interface member returning TimeZoneInfo.Local, so other implementations keep compiling; TimeZoneIds.ToIana (core, Hosting/) turns the zone into the IANA id for TimeZoneId (null when there is none that resolves). The session hands the id to the Extensions slice as IExtensionHost.TimeZoneId.

Process-wide spawners without a session (the scheduler, MCP catalogue discovery) call Processes.Prepare directly (or the DI singleton), so only host hooks run for them. That is deliberate: a host-wide rule belongs in a host hook.

5. Spawners

Spawner Request Explicit env Notes
BashTool BashTool, Command = the model's command, Cwd = ctx.Cwd none Through ctx.PrepareProcessAsync. Then it runs the shell as today with the returned command and cwd, and a cleared-then-copied environment.
ExtensionApi.ExecAsync ExtensionExec, Origin = extension name ExecOptions.Environment Session-bound. ExecOptions.Environment keeps its meaning (set, or null to remove), now on top of the filtered base.
Scheduler ScriptRunner ScheduledScript, Scope = target, Origin = job id, TimeZoneId = the target's zone none Through the DI IProcessSpawnPolicy: jobs fire for chats with no live session. Replaces SchedulerOptions.ScrubbedEnvironment.
MCP McpServerConnection McpServer, Origin = server name, Arguments = expanded args the server's env entries Through ctx.PrepareProcessAsync from the first tool call. The result's env goes to StdioClientTransportOptions.EnvironmentVariables, with InheritEnvironmentVariables = false.
MCP McpCatalogCache discovery as above, Cwd = the temp dir the server's env entries Through api.Processes.Prepare (activation, no session).

The two MCP rows are implemented in extensions/Volcanic.AgenticCore.Extensions.Mcp (McpServerConnection, McpCatalogCache). The scheduler row is implemented in extensions/Volcanic.AgenticCore.Extensions.Scheduler (ScriptRunner).

A spawner whose Prepare throws reports it the way it reports a failed start: bash returns an error result process environment hook failed: <message>, a watcher is Failed(null, "…"), an MCP call or discovery fails with the message. The message comes from the exception, never from the environment.

6. Telegram host

  • 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). 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), with the environment shortcut PROCESS_ENV_ALLOW (comma separated) for Coolify. A deployment that wants the agent to use gh sets PROCESS_ENV_ALLOW=GH_TOKEN.
  • The startup log gains one line: Process environment: <n> deny rules, allow: GH_TOKEN (names only; allow: none when nothing is allowed).
  • Program.cs calls HostProcessHardening.Apply once the options are validated (§8). The ProcessEnvironment section also binds HardenHostProcess.
  • The same SecretValues feed the tool-output redactor (§9), so the bot token never reaches the model in a tool result, whichever file or process it was read from.

7. The Claude Code provider is out of the hook's reach, on purpose

The provider spawns claude -p per model call, plus claude auth login / auth status and the doctor's probes. These processes run the model, not something the model asked for, and the provider already replaces their environment completely: ClaudeArgs.AllowListed keeps only HOME, PATH, USER, LANG, LC_*, the temp and proxy variables, CLAUDE_CONFIG_DIR, NODE_EXTRA_CA_CERTS and the Windows basics, then pins its own flags. FindDeniedVariable refuses to run at all when an Anthropic credential is present. The one variable it adds on top is TZ, set to the session's zone for claude -p (scheduler spec §8), by the provider itself and not through the policy. So none of the deny-listed variables can reach claude today, and the hook would add nothing but risk:

  • a host hook adding variables to claude could change what system/init validation sees or leak an API key into a subscription-only process, which the provider exists to prevent;
  • the provider references only Abstractions and must stay self-contained, as docs/claude-code-provider.md describes.

The Bridge is started by claude and inherits claude's allow-listed environment. The core tools the Bridge advertises run in the core, so a bash call made through Claude Code goes through the hook like any other. docs/claude-code-provider.md gets one sentence saying this.

8. Host process hardening (Linux) — HostProcessHardening

Where. A static core helper, HostProcessHardening.Apply(ProcessEnvironmentOptions options, ILogger logger) in Features/Processes. It is idempotent (a static flag; the first call wins) and is called from two places:

  • the ProcessSpawnPolicy constructor. Every agent-requested spawn resolves the policy first, so any host using AddAgenticCore is hardened before the first bash, watcher or MCP process exists, without remembering to call anything. AddAgenticCore itself can't do it: it only registers services, and the options aren't bound yet;
  • the Telegram host's Program.cs, explicitly, right after the options are validated and before the host starts (so before polling, the scheduler and the Claude Code provider's first claude process). The call is the same, so the policy's later call is a no-op. The headless sample relies on the policy constructor.

Because the first call wins, each test project makes that call itself from a module initializer (NoHostHardening.cs) with HardenHostProcess = false, so no policy a test builds can make the test runner non-dumpable (see Testing).

What. ProcessEnvironmentOptions gains:

/// <summary>Linux: prctl(PR_SET_DUMPABLE, 0) at startup. Turn off only to attach a debugger or dotnet-dump.</summary>
public bool HardenHostProcess { get; set; } = true;

On Linux with the flag on, Apply calls prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) through a P/Invoke to libc ([LibraryImport("libc", SetLastError = true)] static partial int prctl(int option, nuint arg2, nuint arg3, nuint arg4, nuint arg5), PR_SET_DUMPABLE = 4). A non-zero return is logged as a warning with the errno and startup continues: hardening is defence in depth, and failing to start over it would be worse. On success it logs Host process hardened: not dumpable at Information. Everywhere else (!OperatingSystem.IsLinux()) it's a no-op with one Debug line; macOS and Windows have no equivalent that matters for a same-user child here. With the flag off it logs a Warning (Host process hardening disabled: agent processes can read this process's environment).

The diagnostic IPC socket. The .NET runtime listens on $TMPDIR/dotnet-diagnostic-<pid>-*-socket, owned by the host's uid. Its ProcessEnvironment command returns the whole environment, and ApplyStartupHook loads code into the host, so a same-uid child could bypass PR_SET_DUMPABLE entirely. The socket can only be turned off before the runtime starts, through DOTNET_EnableDiagnostics=0 (or DOTNET_EnableDiagnostics_IPC=0). So:

  • the Docker image sets ENV DOTNET_EnableDiagnostics=0 (Docker spec);
  • Apply checks the variable. When hardening is on and neither is 0, it logs a Warning naming the variable, so a bare-host operator sees the gap;
  • the policy doesn't filter the variable from children: an agent's own dotnet builds inheriting it only lose their diagnostics.

Side effects (all accepted, documented):

  • no core dumps of the host (the kernel won't write one for a non-dumpable process), including .NET's own createdump on a crash;
  • /proc/<host pid>/* is owned by root: ps still shows the process, but environ, maps, mem, fd and similar are unreadable to the host's own user (tools that read them for that pid, such as lsof -p, see less);
  • dotnet-dump, dotnet-trace, dotnet-counters, dotnet-gcdump and a debugger can't attach (ptrace is refused, and the IPC socket is off);
  • children are unaffected: a forked child inherits the flag, but execve resets it, so the agent's processes are normally dumpable.

Opt-out for debugging. ProcessEnvironment:HardenHostProcess=false (env ProcessEnvironment__HardenHostProcess=false in Coolify), plus unsetting DOTNET_EnableDiagnostics, restores attach, dumps and traces. Meant for a debugging session, then turned back on.

9. Secret redaction in tool output — SecretRedactor

The environment filter keeps secrets out of children's environments, but a secret can still reach tool output: read from a file under the agent dir, printed by an MCP server that got it through its explicit env, or quoted in an exception message. The agent loop replaces every known secret value before anything downstream sees it.

Contract (Abstractions, Processes/SecretRedaction.cs):

public interface ISecretRedactor
{
    /// <summary>Replaces every known secret value in <paramref name="text"/> with <c>[REDACTED]</c>.</summary>
    string Redact(string text);
}
  • The set is ProcessEnvironmentOptions.SecretValues, the same list §2 filters by value. Values shorter than 8 characters are ignored here too, so a short or common value (true, admin, a port) can't redact half the output. Values of deny-listed variables aren't added automatically: a glob can match a non-secret, and the host knows which values are secret. Duplicates are removed; longer values are replaced first, so a secret that contains another is replaced whole.
  • Matching is ordinal substring, the replacement token is [REDACTED]. Encoded forms (base64, URL-encoded, split across lines) aren't matched.
  • Registration: AddAgenticCore registers SecretRedactor (core, Features/Processes) as the ISecretRedactor singleton with TryAddSingleton. It's built once from the validated options; with an empty set, Redact returns its input. Extensions reach it through IExtensionApi.Redactor (activation, not session-bound) for text they send to the model outside a tool result (the scheduler's watcher output, below).

Where it's applied (core, AgentSession.RunOneAsync, so every tool, built-in, extension or MCP, is covered):

  1. Streamed progress: each partial ToolResult passed to invocation.Report is redacted before AgentSessionEvent.ToolExecutionUpdate and ToolExecutionUpdateEvent are emitted. bash reports snapshots of the output tail, not deltas, so every snapshot is redacted whole.
  2. The result, including every error path: a ToolExecutionException's result, the message of any other exception, Operation aborted, and preparation errors (argument validation, blocked calls). Redacted before ToolResultEvent, so extension handlers only see the redacted text.
  3. Again after ToolResultEvent, so a handler can't reintroduce a secret. Then ToolExecutionEnd and the ToolResultMessage that is persisted and sent to the model.

What's redacted in a ToolResult: the text of every TextContent block, and every string value inside Details (hosts render it, and it's persisted). Images aren't touched. Tools don't redact themselves, and the tool's own truncation runs first, so a secret cut in half at the start of a truncated tail can leave a fragment; that's accepted.

Outside tool results. The scheduler redacts a watcher's stdout before it becomes a prompt, and the stderr tail of a failure alert (2026-09-26-scheduler-design.md §4). User messages and the model's own output aren't redacted: the first comes from an allow-listed user, the second can only contain a secret that already passed through one of the paths above.

10. Docs

  • docs/extensions.md: spawning processes from an extension (ctx.PrepareProcessAsync, api.Processes), the explicit-env rule, ProcessSpawnEvent, and api.Redactor.
  • docs/architecture.md: the Processes slice and the Abstractions contract, host hardening, and where tool output is redacted.
  • docs/telegram-bot.md: Security gains "secrets are removed from the environment of bash, scheduled scripts and MCP servers; PROCESS_ENV_ALLOW lets one through; the bot's own environment can't be read through /proc; the bot token and the other configured secrets are replaced by [REDACTED] in tool output", the ProcessEnvironment:HardenHostProcess opt-out and its side effects, with the not-a-sandbox caveat.
  • docs/claude-code-provider.md: the §7 sentence.
  • CLAUDE.md: Processes in the slice list.

Testing

xunit 2, no network. Tests set variables through an injected environment snapshot (ProcessSpawnPolicy takes a Func<IReadOnlyDictionary<string, string?>>, defaulting to the process environment), never by mutating the test process's environment.

  • Policy (tests/Volcanic.AgenticCore.Tests/Processes/):
    • each default glob removes its examples (OPENAI_API_KEY, Telegram__Speech__ApiKey, Telegram__BotToken, GoogleCalendar__ClientSecret, DB_PASSWORD) and keeps PATH, HOME, LANG, PLAYWRIGHT_BROWSERS_PATH, TOKENIZERS_PARALLELISM;
    • Allow of GITHUB_TOKEN keeps it; Allow of *_TOKEN doesn't re-admit TELEGRAM_BOT_TOKEN; Allow of an explicitly denied name fails validation;
    • a SecretValues match under an innocent name is removed; values under 8 characters are ignored;
    • explicit env beats the filter (a denied name passed explicitly arrives), null removes;
    • TimeZoneId sets TZ, unless explicit env sets it;
    • hooks run in order, can rewrite command, cwd and env, cannot change Purpose/Scope; a throwing hook throws out of Prepare;
    • removed names are logged, values never (a recording logger asserts no secret value appears).
  • Session binding: PrepareProcessAsync fills scope, session id and the session's zone; two extensions' handlers run in load order after the host hooks; api.Processes works during activation.
  • BashTool (real processes, Unix-only like the existing script tests): with OPENAI_API_KEY in the injected snapshot, env output doesn't contain it; a hook's source-style command prefix runs; the session's zone shows in date +%Z; a throwing hook gives an error result and starts no process.
  • ExecAsync: ExecOptions.Environment still sets and removes, on the filtered base.
  • Scheduler and MCP: covered in their specs (the ScriptRunner test that TELEGRAM_BOT_TOKEN is absent; the fake MCP server's env mode).
  • Claude Code: a test that ClaudeArgs.BuildEnvironment output is unchanged when a host hook is configured (the provider never consults the policy).
  • Host hardening (the logic lives in the internal HostProcessHardening.Hardener, built with an injectable Func<int, int> prctl seam that returns the errno, an isLinux flag and an environment reader; the public static Apply uses a shared instance over the real libc):
    • flag on + Linux → prctl called once with PR_SET_DUMPABLE, 0; a second Apply doesn't call it again; a failing prctl logs a warning and doesn't throw, and so does a prctl seam that throws DllNotFoundException (libc doesn't resolve); non-Linux and flag off → not called (flag off logs the warning);
    • the DOTNET_EnableDiagnostics warning appears when the injected environment lacks it, and not when it's 0;
    • Linux-only integration test with the real libc: a helper process (the test runner spawns dotnet <test helper>.dll) applies the hardening and prints ok; the test then runs cat /proc/<helper pid>/environ as the same user and asserts it fails with permission denied, and that the same read succeeds against a helper started with HardenHostProcess=false. The test process itself is never hardened, so test debugging keeps working. The test is skipped when the runner is root: root (CAP_SYS_PTRACE) reads every /proc entry, and a non-root reader couldn't read a root helper's environ even unhardened, so there is nothing to compare. Run it as the bot's own non-root user.
  • Redaction (tests/Volcanic.AgenticCore.Tests/Processes/, FauxProvider + scripted test tools):
    • SecretRedactor: a value inside text becomes [REDACTED]; values under 8 characters are ignored; an overlapping longer secret wins; an empty set returns the same string instance;
    • through the loop: a tool whose result, Details, progress reports and thrown exception message contain a secret → the ToolExecutionUpdate/ToolExecutionEnd events, ToolResultEvent handler input, the persisted ToolResultMessage and the next faux request all contain [REDACTED] and never the value; a ToolResultEvent handler that appends the secret is redacted again;
    • bash running cat secret.txt (a file containing a configured secret) returns [REDACTED].
  • Telegram: with ChatHost, a faux bash call that prints the bot token from a file shows [REDACTED] in the next model request.
  • Telegram: with ChatHost, a faux bash call running env in a chat doesn't show the bot token, whatever variable name carries it.

Out of scope

  • Sandboxing (namespaces, seccomp, a separate OS user per chat), and stopping the agent from reading config files, the agent dir or other files the bot's user can read. (/proc/<host pid>/environ is covered by §8.)
  • <NAME>_FILE secrets (the Docker secrets convention). Deployment is Coolify, which passes secrets as environment variables, so reading them from files would add a path nobody uses. The environment filter, §8 and §9 are what keep those variables from the agent.
  • Redacting encoded or partial secrets, user messages and model output (§9).
  • Filtering files or arguments; only the environment, command and cwd are in the context.
  • Blocking spawns by content (ToolCallEvent.Block already covers tools).
  • Applying the hook to the Claude Code provider's processes (§7).
  • An agent-facing way to see which variables were removed (the log has the names).