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

@@ -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 |