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
bashtool, scheduler watcher scripts, MCP server processes (discovery and per-session) andIExtensionApi.ExecAsyncall build their child environment throughIProcessSpawnPolicy. 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
enventry,ExecOptions.Environment,TZfor the chat's zone) are applied after the filter and are not filtered. So an MCP server that needsGITHUB_TOKENgets 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.ProcessEnvironmentcarriesDeny(names and globs, pre-filled),Allow(names and globs that win overDeny) andSecretValues(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'sspawnHook, host-wide). Inside a session, extensions get a mutableProcessSpawnEventafter the host hooks. Extensions are trusted in-process code that can already readEnvironmentdirectly, 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,ProcessSpawnEventandISecretRedactor(§9) are inVolcanic.AgenticCore.Abstractions(Processes/). The policy is a DI singleton (for process-wide spawners likeJobScheduler) and is also reachable from extensions throughIExtensionApi.Processesand, 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 statusand 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'sbash, a watcher, an MCP server) can neither read/proc/<host pid>/environnorptracethe 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
SecretValuesentry 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 coversbash, 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.Environmentis the complete environment: spawners clearProcessStartInfo.Environmentand copy it in, throughProcessSpawnContextExtensions.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):
- Snapshot the current process environment.
- Remove the entries §2 rejects. Record removed names (never values) for the debug log.
- Apply
request.Environment: set non-null values, remove null ones. Not filtered. - If
request.TimeZoneIdis set and step 3 didn't setTZ, setTZto it. The Docker image hastzdata, so an IANA id works fordate, Python and Node. On WindowsTZisn't used by the OS and the step is skipped. - Run
Hooksin order. A hook may changeCommand,Arguments,CwdandEnvironment;Purpose,Scope,SessionIdandOriginare copied back from the request whatever it returns. A hook that returns null fails the spawn like one that throws. - 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.csadds, after readingTelegramOptionsand 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
Denynames: the defaults already cover every secret the host and the sibling specs read.TELEGRAM_ALLOWED_USER_IDSisn't secret and stays.
- A
ProcessEnvironmentconfiguration section bindsDenyandAllow(added to the defaults, not replacing them), with the environment shortcutPROCESS_ENV_ALLOW(comma separated) for Coolify. A deployment that wants the agent to useghsetsPROCESS_ENV_ALLOW=GH_TOKEN. - The startup log gains one line:
Process environment: <n> deny rules, allow: GH_TOKEN(names only;allow: nonewhen nothing is allowed). Program.cscallsHostProcessHardening.Applyonce the options are validated (§8). TheProcessEnvironmentsection also bindsHardenHostProcess.- The same
SecretValuesfeed 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
claudecould change whatsystem/initvalidation 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.mddescribes.
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
ProcessSpawnPolicyconstructor. Every agent-requested spawn resolves the policy first, so any host usingAddAgenticCoreis hardened before the firstbash, watcher or MCP process exists, without remembering to call anything.AddAgenticCoreitself 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 firstclaudeprocess). 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); Applychecks the variable. When hardening is on and neither is0, 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
dotnetbuilds 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
createdumpon a crash; /proc/<host pid>/*is owned by root:psstill shows the process, butenviron,maps,mem,fdand similar are unreadable to the host's own user (tools that read them for that pid, such aslsof -p, see less);dotnet-dump,dotnet-trace,dotnet-counters,dotnet-gcdumpand a debugger can't attach (ptrace is refused, and the IPC socket is off);- children are unaffected: a forked child inherits the flag, but
execveresets 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:
AddAgenticCoreregistersSecretRedactor(core,Features/Processes) as theISecretRedactorsingleton withTryAddSingleton. It's built once from the validated options; with an empty set,Redactreturns its input. Extensions reach it throughIExtensionApi.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):
- Streamed progress: each partial
ToolResultpassed toinvocation.Reportis redacted beforeAgentSessionEvent.ToolExecutionUpdateandToolExecutionUpdateEventare emitted.bashreports snapshots of the output tail, not deltas, so every snapshot is redacted whole. - 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 beforeToolResultEvent, so extension handlers only see the redacted text. - Again after
ToolResultEvent, so a handler can't reintroduce a secret. ThenToolExecutionEndand theToolResultMessagethat 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, andapi.Redactor.docs/architecture.md: theProcessesslice and the Abstractions contract, host hardening, and where tool output is redacted.docs/telegram-bot.md: Security gains "secrets are removed from the environment ofbash, scheduled scripts and MCP servers;PROCESS_ENV_ALLOWlets 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", theProcessEnvironment:HardenHostProcessopt-out and its side effects, with the not-a-sandbox caveat.docs/claude-code-provider.md: the §7 sentence.CLAUDE.md:Processesin 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 keepsPATH,HOME,LANG,PLAYWRIGHT_BROWSERS_PATH,TOKENIZERS_PARALLELISM; AllowofGITHUB_TOKENkeeps it;Allowof*_TOKENdoesn't re-admitTELEGRAM_BOT_TOKEN;Allowof an explicitly denied name fails validation;- a
SecretValuesmatch 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;
TimeZoneIdsetsTZ, unless explicit env sets it;- hooks run in order, can rewrite command, cwd and env, cannot change
Purpose/Scope; a throwing hook throws out ofPrepare; - removed names are logged, values never (a recording logger asserts no secret value appears).
- each default glob removes its examples (
- Session binding:
PrepareProcessAsyncfills scope, session id and the session's zone; two extensions' handlers run in load order after the host hooks;api.Processesworks during activation. BashTool(real processes, Unix-only like the existing script tests): withOPENAI_API_KEYin the injected snapshot,envoutput doesn't contain it; a hook'ssource-style command prefix runs; the session's zone shows indate +%Z; a throwing hook gives an error result and starts no process.ExecAsync:ExecOptions.Environmentstill sets and removes, on the filtered base.- Scheduler and MCP: covered in their specs (the
ScriptRunnertest thatTELEGRAM_BOT_TOKENis absent; the fake MCP server'senvmode). - Claude Code: a test that
ClaudeArgs.BuildEnvironmentoutput 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 injectableFunc<int, int>prctl seam that returns the errno, anisLinuxflag and an environment reader; the public staticApplyuses a shared instance over the real libc):- flag on + Linux → prctl called once with
PR_SET_DUMPABLE, 0; a secondApplydoesn't call it again; a failing prctl logs a warning and doesn't throw, and so does a prctl seam that throwsDllNotFoundException(libc doesn't resolve); non-Linux and flag off → not called (flag off logs the warning); - the
DOTNET_EnableDiagnosticswarning appears when the injected environment lacks it, and not when it's0; - Linux-only integration test with the real libc: a helper process (the test runner spawns
dotnet <test helper>.dll) applies the hardening and printsok; the test then runscat /proc/<helper pid>/environas the same user and asserts it fails with permission denied, and that the same read succeeds against a helper started withHardenHostProcess=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/procentry, and a non-root reader couldn't read a root helper'senvironeven unhardened, so there is nothing to compare. Run it as the bot's own non-root user.
- flag on + Linux → prctl called once with
- 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 → theToolExecutionUpdate/ToolExecutionEndevents,ToolResultEventhandler input, the persistedToolResultMessageand the next faux request all contain[REDACTED]and never the value; aToolResultEventhandler that appends the secret is redacted again; bashrunningcat secret.txt(a file containing a configured secret) returns[REDACTED].
- Telegram: with
ChatHost, a fauxbashcall that prints the bot token from a file shows[REDACTED]in the next model request. - Telegram: with
ChatHost, a fauxbashcall runningenvin 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>/environis covered by §8.) <NAME>_FILEsecrets (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.Blockalready 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).