docs(processes): process spawn policy, hardening and redaction

extensions.md: api.Processes and api.Redactor, ExecAsync on the policy,
ProcessSpawnEvent, ctx.TimeZone and PrepareProcessAsync, and a Spawning
processes section. architecture.md: Processes slice, the Abstractions
contract, a Processes and secrets section, and where the loop redacts.
CLAUDE.md: Processes slice and bullet.

Spec: status implemented (scheduler and MCP spawners come with their specs);
a hook returning null fails the spawn; test projects consume the one-shot
hardening from a module initializer; the Hardener seam; 'allow: none' in the
Telegram log line.
This commit is contained in:
EugeneTes
2026-09-26 19:23:09 +02:00
parent 694ef3594e
commit 5f33d139b3
4 changed files with 106 additions and 17 deletions

View File

@@ -48,9 +48,9 @@ Read `docs/architecture.md` first. Key points:
`IUserInterface`, and `AgentSessionEvent`. Extensions reference **only** this assembly, so it stays small and
stable. Don't let runtime types leak into it.
- **`src/Volcanic.AgenticCore`** is organised as **vertical slices** under `Features/` (`Agent`, `Sessions`, `Models`,
`Tools`, `Extensions`, `Commands`, `SystemPrompt`, `Compaction`). Each slice exposes one entry type
`Tools`, `Extensions`, `Commands`, `SystemPrompt`, `Compaction`, `Processes`). Each slice exposes one entry type
(`AgentSession`, `SessionManager`, `ModelRegistry`, `ToolRegistry`, `ExtensionRunner`, `CommandRegistry`,
`SystemPromptBuilder`, `Compactor`). Slices talk only through those entry types and the Abstractions.
`SystemPromptBuilder`, `Compactor`, `ProcessSpawnPolicy`). Slices talk only through those entry types and the Abstractions.
`Hosting/` has `AddAgenticCore(...)` and `AgenticCoreOptions`; `Testing/FauxProvider.cs` is the scripted provider
that tests and `--faux` use.
- **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are
@@ -63,6 +63,10 @@ Read `docs/architecture.md` first. Key points:
Tool preparation runs in source order, execution runs in parallel by default, results are appended in source order.
Failed assistant messages are persisted but dropped from model context. Context overflow triggers one compaction
and a retry.
- **Processes**: every agent-requested process gets its environment from `IProcessSpawnPolicy`
(`AgenticCoreOptions.ProcessEnvironment`: deny-list, `Allow`, `SecretValues`, host hooks); `AgentSession` redacts
`SecretValues` from tool output; `HostProcessHardening` makes the host non-dumpable on Linux, once per process. Test projects
apply it with `HardenHostProcess = false` from a module initializer (`NoHostHardening.cs`).
- **Persistence**: a session is a tree of JSONL entries (`id`/`parentId` + leaf pointer) under
`~/.volcanic/agent/sessions/<scope>/` (`VOLCANIC_AGENT_DIR` overrides the root). Compaction entries replace older
messages when context is built.

View File

@@ -12,8 +12,10 @@ tests/* xunit
`Abstractions` is deliberately small and stable: messages and content blocks, `Model` and `IProvider`,
the streaming event protocol, `ITool`, the extension API and its events, session entries and stores,
`IUserInterface`, and the `AgentSessionEvent` family a UI subscribes to. Extensions reference only this
assembly, which is what allows them to be loaded from separate directories.
`IUserInterface`, the `AgentSessionEvent` family a UI subscribes to, and the process contract in `Processes/`
(`IProcessSpawnPolicy`, `ProcessSpawnRequest`/`ProcessSpawnContext`, `ProcessSpawnHook`, `ProcessSpawnEvent`,
`ISecretRedactor`). Extensions reference only this assembly, which is what allows them to be loaded from separate
directories.
## Vertical slices
@@ -30,6 +32,7 @@ owns everything for that feature and exposes one entry type:
| `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment |
| `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt |
| `Agent` | `AgentSession`, `AgentSessionFactory` | The conversation facade and the loop |
| `Processes` | `ProcessSpawnPolicy` | The environment, command and cwd of every agent-requested process (deny-list, allow-list, secret values, host hooks), host hardening, secret redaction |
Slices talk to each other only through their entry types and the Abstractions contracts. `AgentSession`
is the composition root of one conversation; a host never touches the other slices directly.
@@ -70,6 +73,8 @@ loop:
context → extensions may rewrite the message list
stream the provider → message_start / message_update* / message_end (with retry + overflow recovery)
tool calls? → tool_call hook, execute (parallel by default), tool_result hook, tool result messages
(every result, progress update and error is redacted before tool_result handlers,
and again after them, before tool_execution_end and the persisted message)
turn_end
more tool calls or steering messages or continue requested? → next turn
follow-up queued? → next turn
@@ -101,6 +106,34 @@ Two independent event systems exist:
(`Block`, `Cancel`, `MarkHandled`) or setting properties (`Text`, `Payload`, `Message`, `Override`).
Handlers run sequentially in load order and see each other's changes; some events short-circuit.
## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, and, with their specs, scheduler
scripts and MCP servers) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
`AgenticCoreOptions.ProcessEnvironment`:
```
inherit the host environment → filter (Deny, Allow, SecretValues) → explicit request.Environment (never filtered)
→ TZ from TimeZoneId → host hooks → ProcessSpawnEvent to extensions (only in a session, via PrepareProcessAsync)
```
The result is the child's complete environment; spawners clear `ProcessStartInfo.Environment` and copy it in. A
throwing hook or handler fails the spawn instead of falling back to the inherited environment.
`HostProcessHardening` makes the host non-dumpable on Linux (`prctl(PR_SET_DUMPABLE, 0)`), so a same-user child
can't read `/proc/<host pid>/environ` or `ptrace` the host. It runs from the `ProcessSpawnPolicy` constructor, or
explicitly from a host at startup (the Telegram host does), and only once. It warns when `DOTNET_EnableDiagnostics=0`
is missing, since the diagnostic socket would hand out the same environment. `HardenHostProcess = false` turns it off
for debugging. Test projects make the one call with the flag off from a module initializer, so the test runner stays
dumpable.
`ISecretRedactor` replaces every `SecretValues` entry with `[REDACTED]`. `AgentSession` applies it to tool
results, progress updates and error messages (see the loop above), so every tool is covered in one place.
The Claude Code provider's own `claude` processes are outside the policy on purpose: they run the model and already
get an allow-listed environment. None of this is a sandbox: the agent still runs as the host's OS user and can read
the files that user can read.
## Persistence
A session is a tree of entries (`message`, `model_change`, `thinking_level_change`, `compaction`, `custom`,

View File

@@ -69,12 +69,14 @@ Register it on the host (`o.AddExtension<HelloExtension>()`), pass it per sessio
| `SetModelAsync`, `GetThinkingLevel`, `SetThinkingLevel` | Session-scoped model control. |
| `GetActiveTools`, `SetActiveTools`, `GetAllTools`, `GetCommands` | Introspection and tool gating. |
| `GetSessionName`, `SetSessionName` | Display name. |
| `ExecAsync(command, args, options)` | Run a process in the session directory. |
| `ExecAsync(command, args, options)` | Run a process in the session directory. Its environment comes from the process policy; `ExecOptions.Environment` sets (or, with null, removes) variables on top of the filtered base. |
| `Events` | Bus shared by all extensions in the session (`Emit(channel, data)`, `On(channel, handler)`). |
| `Logger` | An `ILogger` named after the extension. |
| `Processes` | The host's `IProcessSpawnPolicy`: host hooks only, no extension events. Use it where there's no session, e.g. during activation. |
| `Redactor` | `Redact(text)` replaces the host's known secrets with `[REDACTED]`, for text you send to the model outside a tool result. |
Action members throw `ExtensionNotBoundException` when called during `ActivateAsync`: do registration there
and start processes or timers in a `SessionStartEvent` handler. After a reload or session replacement the old
and start processes or timers in a `SessionStartEvent` handler. `Processes` and `Redactor` work during activation. After a reload or session replacement the old
API and contexts throw `StaleExtensionContextException`.
## Events
@@ -98,20 +100,22 @@ mutable; change them to influence the runtime.
| `ToolCallEvent` | Before a tool runs | Patch `Input`, or `Block(reason)`; a throwing handler blocks |
| `ToolResultEvent` | After a tool ran | Edit `Content`, `Details`, `IsError` |
| `ToolExecutionStart/Update/EndEvent` | Tool lifecycle | notify |
| `ProcessSpawnEvent` | Before a process the agent asked for starts | Replace `Context` (command, cwd, environment); a throwing handler fails the spawn |
| `SessionBeforeCompactEvent` | Compaction about to run | `Cancel()` or set `Override` to supply your own summary |
| `SessionCompactEvent` | Compaction entry written | notify |
| `ModelSelectEvent` / `ThinkingLevelSelectEvent` | Settings changed | notify |
Dispatch rules: handlers run in extension load order, then registration order; exceptions are reported as
`extension_error` session events and dispatch continues; `Cancel`, `Block` and `MarkHandled` stop further
handlers.
`extension_error` session events and dispatch continues (except `ProcessSpawnEvent`, where the exception also
fails the spawn); `Cancel`, `Block` and `MarkHandled` stop further handlers.
## The context object
`IExtensionContext` gives handlers and tools: `Cwd`, `SessionId`, `HasUi`, `Ui` (select/confirm/input/notify/
`IExtensionContext` gives handlers and tools: `Cwd`, `SessionId`, `TimeZone` (the session's zone), `HasUi`, `Ui` (select/confirm/input/notify/
status), `SessionManager` (read-only entries and branch), `ModelRegistry` (models and provider-neutral
`CompleteAsync` for nested model calls), `Model`, `ThinkingLevel`, `Signal` (cancelled on abort), `IsIdle()`,
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`.
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`,
`PrepareProcessAsync(request)` (see [spawning processes](#spawning-processes)).
Command handlers get `IExtensionCommandContext`, which adds `WaitForIdleAsync`, `NewSessionAsync` and
`SwitchSessionAsync`.
@@ -130,6 +134,50 @@ builds a tool from a lambda.
- `PromptSnippet` puts one line in the system prompt's tools section; `PromptGuidelines` adds bullets.
- Report progress through `invocation.Report(partial)`; hosts see it as `tool_execution_update`.
## Spawning processes
A process started on the agent's behalf must not inherit the host's environment as it is: that's how
`TELEGRAM_BOT_TOKEN` or `OPENAI_API_KEY` ends up in tool output. Build its environment through the process policy
(`Volcanic.AgenticCore.Abstractions.Processes`):
```csharp
var spawn = await ctx.PrepareProcessAsync(new ProcessSpawnRequest
{
Purpose = ProcessPurpose.ExtensionExec,
Command = "gh",
Arguments = ["issue", "list"],
Cwd = ctx.Cwd,
Environment = new Dictionary<string, string?> { ["GH_TOKEN"] = myToken },
Origin = "my-extension",
}, ct);
var psi = new ProcessStartInfo(spawn.Command) { WorkingDirectory = spawn.Cwd, UseShellExecute = false };
foreach (var a in spawn.Arguments) psi.ArgumentList.Add(a);
psi.Environment.Clear();
foreach (var (k, v) in spawn.Environment) psi.Environment[k] = v;
```
- In a session, call `ctx.PrepareProcessAsync`. It fills `Scope`, `SessionId` and (unless you set it)
`TimeZoneId` from the session, runs the policy, then emits `ProcessSpawnEvent` to extensions in load order.
Without a session (during activation, or from a process-wide service), call `api.Processes.Prepare(request)`:
only the host hooks run.
- The returned `ProcessSpawnContext.Environment` is the **complete** environment. Clear
`ProcessStartInfo.Environment` and copy it in; don't merge it with the inherited one.
- The policy starts from the host's environment, removes what the deny-list (`*_API_KEY`, `*_TOKEN`, `*_SECRET`,
`*_PASSWORD`, their `Section__Key` forms, and a few names), `Allow` and `SecretValues` reject, then applies
`request.Environment`, then sets `TZ` from `TimeZoneId`, then runs the host hooks.
- **Variables you pass explicitly are applied after the deny-list and never filtered.** A process that needs a
token gets it only when you pass it, and no other process sees it. A null value removes the variable.
- Host hooks and `ProcessSpawnEvent` handlers are synchronous rewrites of command, arguments, cwd and environment;
do any I/O in the spawner. A hook or handler that throws fails the spawn (the exception comes out of
`PrepareProcessAsync`), and nothing falls back to the inherited environment. Report it the way you report a
failed start; the built-in `bash` returns `process environment hook failed: <message>`.
- `ExecAsync` already does all of this with `Purpose = ExtensionExec` and `Origin` = your extension's name.
- Tool output is redacted by the agent loop: every `SecretValues` entry in a tool result, progress update or
error becomes `[REDACTED]` before the model, the transcript, hosts or `ToolResultEvent` handlers see it. Tools
don't redact themselves. Text you send to the model some other way (`SendMessage`, `SendUserMessage`) isn't
redacted: pass it through `api.Redactor.Redact` first.
## Where state should live
| State | Use |

View File

@@ -1,7 +1,7 @@
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
Date: 2026-09-26
Status: draft
Status: implemented (the scheduler `ScriptRunner` and the MCP spawners in §5 come with their own specs)
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).
@@ -170,7 +170,8 @@ is also allowed); `DefaultDeny` and `MinimumSecretLength` (8) are public on the
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.
`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
@@ -238,7 +239,8 @@ message. The message comes from the exception, never from the environment.
- 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).
- 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
@@ -275,8 +277,9 @@ advertises run in the core, so a `bash` call made through Claude Code goes throu
(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.
Tests that build a `ProcessSpawnPolicy` pass options with `HardenHostProcess = false`, so the test runner is never
made non-dumpable (see Testing).
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:
@@ -409,8 +412,9 @@ process's environment.
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** (`HostProcessHardening` takes an injectable `Func<int, int>` prctl seam and an
`IsLinux` flag for unit tests):
- **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; 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`;