forked from admin/VolcanicAgenticCore
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:
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user