detached 99c9ba8

feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext

Clears every deferred extension todo in one push:

1) Interception expands to three events: tool_call (already shipped),
   turn_start (gate the turn before the model call, e.g. rate-limit /
   business-hour), and assistant_message (suppress or rewrite the
   user-visible text while keeping the model's original output in
   the transcript).

2) Tool-call args can now be rewritten mid-flight. An interceptor
   returning modified_args replaces the JSON the tool actually
   receives, without the model seeing the rewrite. Chains: each
   subscriber sees the previous one's output, letting guards
   successively redact / patch / augment. Invalid JSON is dropped
   safely.

3) /reload-ext hot-reloads every extension without restarting zot.
   The manager gracefully shuts down all running subprocesses,
   re-reads extension.json from disk, respawns (including --ext
   paths remembered from startup), and the host rebuilds the agent's
   tool registry in-place so freshly-registered tools are callable
   immediately.

Wire-format changes (extproto):
- EventInterceptResponseFromExt gains modified_args and replace_text
  fields (both optional, ignored when block=true).
- EventInterceptFromHost gains Step (for turn_start) and Text (for
  assistant_message) alongside the existing tool_call payload.

Core agent changes:
- BeforeToolExecute signature now returns (allowed, reason,
  modifiedArgs json.RawMessage). Non-nil+valid JSON args replace
  tc.Arguments before Tool.Execute runs.
- New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking
  cancels the turn with an EvTurnEnd{StopError} carrying the reason.
- New BeforeAssistantMessage hook, invoked after finalMsg is
  assembled but before the EvAssistantMessage emit. Supports
  suppress (block=true) and text rewrite (replace_text). Transcript
  always gets the original; UI gets the rewritten text.
- New SetTools(reg) so /reload-ext can swap the registry on the
  live agent under the agent mutex.

Manager changes:
- InterceptToolCall now returns InterceptResult (Block, Reason,
  ModifiedArgs, ReplaceText), with a chain that folds rewrites.
- New InterceptTurnStart and InterceptAssistantMessage.
- New Reload(ctx, grace) tears down and respawns everything,
  returning ReloadStats{Stopped, Loaded, Ready, Errors}.
- New SetOnReload(fn) callback the host uses to rebuild the agent
  tool registry after a reload.
- LoadExplicit remembers --ext paths so Reload respawns them.
- subscribe accepts "tool_call", "turn_start", "assistant_message"
  under "intercept".

SDK (pkg/zotext):
- New handler types: ToolCallHandler, TurnStartHandler,
  AssistantMessageHandler, and their decision structs
  (ToolCallDecision with ModifiedArgs, AssistantMessageDecision
  with ReplaceText).
- New registration methods: InterceptToolCallX (rich variant of
  the existing InterceptToolCall), InterceptTurnStart,
  InterceptAssistantMessage.
- dispatchIntercept routes per-event with panic recovery and
  always emits exactly one event_intercept_response.

TUI:
- /reload-ext slash command registered in slashCatalog and
  runSlash. Added to slashCancelsTurn so it waits for idle like
  /compact does.
- runReloadExt shows a "reloading extensions..." status, runs the
  Manager.Reload on a goroutine, and reports the resulting stats.

Tests:
- internal/core/intercept_test.go: verifies args are actually
  rewritten on the way to Tool.Execute, malformed JSON is ignored,
  and block surfaces the reason as an error ToolResult.
- internal/agent/extensions/intercept_test.go: end-to-end with a
  bash extension subprocess that blocks rm -rf, rewrites other bash
  args to "echo GUARDED:", passes through read calls, allows
  turn_start, and redacts SECRET in assistant messages. Second test
  verifies Reload respawns the subprocess, re-registers its command,
  and fires the onReload callback.

Docs:
- docs/extensions.md: rewrote the intercept section to cover all
  three events, added a table of event_intercept_response fields,
  documented the /reload-ext hot-reload command, expanded the SDK
  section with examples of every handler, moved the old "future"
  items into a shipped Phase 4.
- README.md: extensions summary mentions intercept beyond tool_call,
  /reload-ext added to the slash-commands table and to the
  turn-cancel list in "Queued messages".
commit
99c9ba806243f46d278ebeee62b86180e501a642
author
patriceckhart · · 2026-04-19 15:02 UTC
parent
9f031f9
signature
unsigned
refs
tag: v0.0.35
15 files · +1203 -145
M README.md +3 -2
@@ -195,6 +195,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
195195 | `/compact` | Summarize the transcript into one message to free up context. |
196196 | `/lock` | Confine tools to the current directory. |
197197 | `/unlock` | Allow tools to touch paths outside again. |
198+| `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
198199 | `/clear` | Clear the chat transcript. |
199200 | `/exit` | Exit zot. |
200201
@@ -270,7 +271,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
270271
271272 You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` or `ctrl+c` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
272273
273-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/lock`, `/unlock`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
274+Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/lock`, `/unlock`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
274275
275276 ## Keys (interactive mode)
276277
@@ -306,7 +307,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
306307
307308 ## Extensions
308309
309-zot can be extended in any language via a subprocess + JSON-RPC protocol. Extensions can register slash commands, expose tools to the model, and intercept tool calls for permission gates. None are installed by default; opt in explicitly.
310+zot can be extended in any language via a subprocess + JSON-RPC protocol. Extensions can register slash commands, expose tools to the model, intercept tool calls (block or rewrite args), gate whole turns before the model is called, and rewrite the assistant's visible text before it reaches the user. None are installed by default; opt in explicitly. Hot-reload any time with `/reload-ext`.
310311
311312 ### Install and manage
312313
@@ -195,6 +195,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
195 | `/compact` | Summarize the transcript into one message to free up context. |195 | `/compact` | Summarize the transcript into one message to free up context. |
196 | `/lock` | Confine tools to the current directory. |196 | `/lock` | Confine tools to the current directory. |
197 | `/unlock` | Allow tools to touch paths outside again. |197 | `/unlock` | Allow tools to touch paths outside again. |
198+| `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
198 | `/clear` | Clear the chat transcript. |199 | `/clear` | Clear the chat transcript. |
199 | `/exit` | Exit zot. |200 | `/exit` | Exit zot. |
200 201
@@ -270,7 +271,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
270 271
271 You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` or `ctrl+c` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.272 You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` or `ctrl+c` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
272 273
273-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/lock`, `/unlock`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.274+Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/lock`, `/unlock`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
274 275
275 ## Keys (interactive mode)276 ## Keys (interactive mode)
276 277
@@ -306,7 +307,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
306 307
307 ## Extensions308 ## Extensions
308 309
309-zot can be extended in any language via a subprocess + JSON-RPC protocol. Extensions can register slash commands, expose tools to the model, and intercept tool calls for permission gates. None are installed by default; opt in explicitly.310+zot can be extended in any language via a subprocess + JSON-RPC protocol. Extensions can register slash commands, expose tools to the model, intercept tool calls (block or rewrite args), gate whole turns before the model is called, and rewrite the assistant's visible text before it reaches the user. None are installed by default; opt in explicitly. Hot-reload any time with `/reload-ext`.
310 311
311 ### Install and manage312 ### Install and manage
312 313
M docs/extensions.md +100 -14
@@ -212,23 +212,51 @@ which it wants to intercept. Send once after `hello`, before `ready`.
212212 ```json
213213 {"type":"subscribe",
214214 "events":["session_start","turn_start","tool_call","turn_end","assistant_message"],
215- "intercept":["tool_call"]}
215+ "intercept":["tool_call","turn_start","assistant_message"]}
216216 ```
217217
218218 Recognised event names: `session_start`, `turn_start`, `turn_end`,
219-`tool_call`, `assistant_message`. Only `tool_call` is interceptable
220-in this version; other names listed under `intercept` are ignored.
219+`tool_call`, `assistant_message`.
220+
221+Interceptable events:
222+
223+- `tool_call`: block the call (model sees `reason` as the tool
224+ error) or rewrite args via `modified_args`.
225+- `turn_start`: block the turn before the model is called. Useful
226+ for rate-limiting and business-hour gates. `reason` is shown to
227+ the user as a status line. No rewrite supported.
228+- `assistant_message`: suppress the message via `block`, or rewrite
229+ the user-visible text via `replace_text`. The model's original
230+ text stays in the transcript so the model sees what it actually
231+ said on subsequent turns.
221232
222233 #### `event_intercept_response`
223234
224-Reply to an `event_intercept` from the host. `block: true` refuses
225-the action; `reason` is shown to the model as the tool error text.
235+Reply to an `event_intercept` from the host. All fields default to
236+"allow, pass through unmodified".
237+
238+| field | meaning |
239+|---|---|
240+| `block` | `true` refuses the action. For `tool_call`, `reason` is shown to the model; for `turn_start` / `assistant_message`, `reason` is shown to the user. |
241+| `reason` | refusal text (on block) or pass-through note. |
242+| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
243+| `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |
244+
226245 Missing the response within 5s is treated as "allow" (i.e. an
227-unresponsive extension never stalls the agent).
246+unresponsive extension never stalls the agent). When multiple
247+extensions subscribe to the same event, they're consulted serially;
248+the first `block` wins and rewrites (args / text) chain: each
249+subsequent interceptor sees the previous one's output.
228250
229251 ```json
230252 {"type":"event_intercept_response","id":"...",
231253 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
254+
255+{"type":"event_intercept_response","id":"...",
256+ "modified_args":{"command":"echo GUARDED: ls"}}
257+
258+{"type":"event_intercept_response","id":"...",
259+ "replace_text":"[redacted]"}
232260 ```
233261
234262 #### `command_response` (reply to `command_invoked`)
@@ -319,17 +347,26 @@ Lifecycle notification for events the extension subscribed to via
319347
320348 #### `event_intercept`
321349
322-Sent when zot wants to give the extension a chance to block a
323-lifecycle event before it happens. Same payload shape as `event`.
324-Reply with `event_intercept_response` within 5s; missing the deadline
325-is treated as "allow".
350+Sent when zot wants to give the extension a chance to block, modify,
351+or annotate a lifecycle event before it happens. Reply with
352+`event_intercept_response` within 5s; missing the deadline is
353+treated as "allow".
326354
327-Only `event: "tool_call"` is sent in this version.
355+Payload fields depend on the event:
328356
329357 ```json
358+// tool_call: includes the tool id, name, and parsed args
330359 {"type":"event_intercept","id":"...","event":"tool_call",
331360 "tool_id":"...","tool_name":"bash",
332361 "tool_args":{"command":"rm -rf /tmp/foo"}}
362+
363+// turn_start: includes the step number
364+{"type":"event_intercept","id":"...","event":"turn_start",
365+ "step":3}
366+
367+// assistant_message: includes the assembled text
368+{"type":"event_intercept","id":"...","event":"assistant_message",
369+ "text":"here is your api key: sk-ant-..."}
333370 ```
334371
335372 #### `shutdown`
@@ -406,6 +443,37 @@ func main() {
406443 Build with `go build -o hello .`, drop the binary + an `extension.json`
407444 into `$ZOT_HOME/extensions/hello/`.
408445
446+The SDK has four interceptor hooks, all optional:
447+
448+```go
449+// Refuse calls or rewrite args before they run.
450+ext.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
451+ if tool == "bash" { /* inspect args, return false, reason */ }
452+ return true, ""
453+})
454+
455+// Richer variant: returns ToolCallDecision so you can also rewrite
456+// args via ModifiedArgs.
457+ext.InterceptToolCallX(func(tool string, args json.RawMessage) zotext.ToolCallDecision {
458+ return zotext.ToolCallDecision{
459+ ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
460+ }
461+})
462+
463+// Block the next turn before the model is called.
464+ext.InterceptTurnStart(func(step int) zotext.TurnStartDecision {
465+ if time.Now().Hour() < 9 { return zotext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
466+ return zotext.TurnStartDecision{}
467+})
468+
469+// Scrub or rewrite the assistant's final text before the user sees it.
470+ext.InterceptAssistantMessage(func(text string) zotext.AssistantMessageDecision {
471+ return zotext.AssistantMessageDecision{
472+ ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
473+ }
474+})
475+```
476+
409477 See:
410478 - `examples/extensions/hello/` — slash commands
411479 - `examples/extensions/clock/` — slash commands in plain Node, no SDK
@@ -413,6 +481,16 @@ See:
413481 - `examples/extensions/guard/` — event subscriptions + tool-call
414482 interception (refuses dangerous bash patterns)
415483
484+### Hot reload
485+
486+Type `/reload-ext` in the TUI to tear down every running extension
487+subprocess, re-read the manifests from disk, and respawn the set.
488+The agent's tool registry is rebuilt automatically, so freshly-
489+registered extension tools become callable without restarting zot.
490+Useful while developing an extension: edit, save, `/reload-ext`,
491+done. Explicit `--ext` paths are remembered and reloaded alongside
492+discovered extensions.
493+
416494 ### TypeScript / Python
417495
418496 These SDKs aren't in the main repo yet; the wire format is small
@@ -449,7 +527,15 @@ Phase 3 (shipped):
449527 `tool_call`, `assistant_message`)
450528 - [x] tool-call interception (block before execution)
451529
530+Phase 4 (shipped):
531+- [x] interception for `turn_start` and `assistant_message` (in
532+ addition to `tool_call`)
533+- [x] modify tool args mid-flight via `modified_args`
534+- [x] rewrite user-visible assistant text via `replace_text`
535+- [x] `/reload-ext` slash command (hot-reload without restarting zot)
536+
452537 Future (no firm timeline):
453-- [ ] interception for additional events beyond `tool_call`
454-- [ ] modify (not just block) tool args mid-flight
455-- [ ] `/reload-ext` slash command (hot-reload without restarting zot)
538+- [ ] TypeScript and Python SDK packages (currently the wire format
539+ is stable enough to hand-roll, see the Python quick-start)
540+- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
541+- [ ] per-extension permission scopes (today: full user privileges)
@@ -212,23 +212,51 @@ which it wants to intercept. Send once after `hello`, before `ready`.
212 ```json212 ```json
213 {"type":"subscribe",213 {"type":"subscribe",
214 "events":["session_start","turn_start","tool_call","turn_end","assistant_message"],214 "events":["session_start","turn_start","tool_call","turn_end","assistant_message"],
215- "intercept":["tool_call"]}215+ "intercept":["tool_call","turn_start","assistant_message"]}
216 ```216 ```
217 217
218 Recognised event names: `session_start`, `turn_start`, `turn_end`,218 Recognised event names: `session_start`, `turn_start`, `turn_end`,
219-`tool_call`, `assistant_message`. Only `tool_call` is interceptable219+`tool_call`, `assistant_message`.
220-in this version; other names listed under `intercept` are ignored.220+
221+Interceptable events:
222+
223+- `tool_call`: block the call (model sees `reason` as the tool
224+ error) or rewrite args via `modified_args`.
225+- `turn_start`: block the turn before the model is called. Useful
226+ for rate-limiting and business-hour gates. `reason` is shown to
227+ the user as a status line. No rewrite supported.
228+- `assistant_message`: suppress the message via `block`, or rewrite
229+ the user-visible text via `replace_text`. The model's original
230+ text stays in the transcript so the model sees what it actually
231+ said on subsequent turns.
221 232
222 #### `event_intercept_response`233 #### `event_intercept_response`
223 234
224-Reply to an `event_intercept` from the host. `block: true` refuses235+Reply to an `event_intercept` from the host. All fields default to
225-the action; `reason` is shown to the model as the tool error text.236+"allow, pass through unmodified".
237+
238+| field | meaning |
239+|---|---|
240+| `block` | `true` refuses the action. For `tool_call`, `reason` is shown to the model; for `turn_start` / `assistant_message`, `reason` is shown to the user. |
241+| `reason` | refusal text (on block) or pass-through note. |
242+| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
243+| `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |
244+
226 Missing the response within 5s is treated as "allow" (i.e. an245 Missing the response within 5s is treated as "allow" (i.e. an
227-unresponsive extension never stalls the agent).246+unresponsive extension never stalls the agent). When multiple
247+extensions subscribe to the same event, they're consulted serially;
248+the first `block` wins and rewrites (args / text) chain: each
249+subsequent interceptor sees the previous one's output.
228 250
229 ```json251 ```json
230 {"type":"event_intercept_response","id":"...",252 {"type":"event_intercept_response","id":"...",
231 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}253 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
254+
255+{"type":"event_intercept_response","id":"...",
256+ "modified_args":{"command":"echo GUARDED: ls"}}
257+
258+{"type":"event_intercept_response","id":"...",
259+ "replace_text":"[redacted]"}
232 ```260 ```
233 261
234 #### `command_response` (reply to `command_invoked`)262 #### `command_response` (reply to `command_invoked`)
@@ -319,17 +347,26 @@ Lifecycle notification for events the extension subscribed to via
319 347
320 #### `event_intercept`348 #### `event_intercept`
321 349
322-Sent when zot wants to give the extension a chance to block a350+Sent when zot wants to give the extension a chance to block, modify,
323-lifecycle event before it happens. Same payload shape as `event`.351+or annotate a lifecycle event before it happens. Reply with
324-Reply with `event_intercept_response` within 5s; missing the deadline352+`event_intercept_response` within 5s; missing the deadline is
325-is treated as "allow".353+treated as "allow".
326 354
327-Only `event: "tool_call"` is sent in this version.355+Payload fields depend on the event:
328 356
329 ```json357 ```json
358+// tool_call: includes the tool id, name, and parsed args
330 {"type":"event_intercept","id":"...","event":"tool_call",359 {"type":"event_intercept","id":"...","event":"tool_call",
331 "tool_id":"...","tool_name":"bash",360 "tool_id":"...","tool_name":"bash",
332 "tool_args":{"command":"rm -rf /tmp/foo"}}361 "tool_args":{"command":"rm -rf /tmp/foo"}}
362+
363+// turn_start: includes the step number
364+{"type":"event_intercept","id":"...","event":"turn_start",
365+ "step":3}
366+
367+// assistant_message: includes the assembled text
368+{"type":"event_intercept","id":"...","event":"assistant_message",
369+ "text":"here is your api key: sk-ant-..."}
333 ```370 ```
334 371
335 #### `shutdown`372 #### `shutdown`
@@ -406,6 +443,37 @@ func main() {
406 Build with `go build -o hello .`, drop the binary + an `extension.json`443 Build with `go build -o hello .`, drop the binary + an `extension.json`
407 into `$ZOT_HOME/extensions/hello/`.444 into `$ZOT_HOME/extensions/hello/`.
408 445
446+The SDK has four interceptor hooks, all optional:
447+
448+```go
449+// Refuse calls or rewrite args before they run.
450+ext.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
451+ if tool == "bash" { /* inspect args, return false, reason */ }
452+ return true, ""
453+})
454+
455+// Richer variant: returns ToolCallDecision so you can also rewrite
456+// args via ModifiedArgs.
457+ext.InterceptToolCallX(func(tool string, args json.RawMessage) zotext.ToolCallDecision {
458+ return zotext.ToolCallDecision{
459+ ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
460+ }
461+})
462+
463+// Block the next turn before the model is called.
464+ext.InterceptTurnStart(func(step int) zotext.TurnStartDecision {
465+ if time.Now().Hour() < 9 { return zotext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
466+ return zotext.TurnStartDecision{}
467+})
468+
469+// Scrub or rewrite the assistant's final text before the user sees it.
470+ext.InterceptAssistantMessage(func(text string) zotext.AssistantMessageDecision {
471+ return zotext.AssistantMessageDecision{
472+ ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
473+ }
474+})
475+```
476+
409 See:477 See:
410 - `examples/extensions/hello/` — slash commands478 - `examples/extensions/hello/` — slash commands
411 - `examples/extensions/clock/` — slash commands in plain Node, no SDK479 - `examples/extensions/clock/` — slash commands in plain Node, no SDK
@@ -413,6 +481,16 @@ See:
413 - `examples/extensions/guard/` — event subscriptions + tool-call481 - `examples/extensions/guard/` — event subscriptions + tool-call
414 interception (refuses dangerous bash patterns)482 interception (refuses dangerous bash patterns)
415 483
484+### Hot reload
485+
486+Type `/reload-ext` in the TUI to tear down every running extension
487+subprocess, re-read the manifests from disk, and respawn the set.
488+The agent's tool registry is rebuilt automatically, so freshly-
489+registered extension tools become callable without restarting zot.
490+Useful while developing an extension: edit, save, `/reload-ext`,
491+done. Explicit `--ext` paths are remembered and reloaded alongside
492+discovered extensions.
493+
416 ### TypeScript / Python494 ### TypeScript / Python
417 495
418 These SDKs aren't in the main repo yet; the wire format is small496 These SDKs aren't in the main repo yet; the wire format is small
@@ -449,7 +527,15 @@ Phase 3 (shipped):
449 `tool_call`, `assistant_message`)527 `tool_call`, `assistant_message`)
450 - [x] tool-call interception (block before execution)528 - [x] tool-call interception (block before execution)
451 529
530+Phase 4 (shipped):
531+- [x] interception for `turn_start` and `assistant_message` (in
532+ addition to `tool_call`)
533+- [x] modify tool args mid-flight via `modified_args`
534+- [x] rewrite user-visible assistant text via `replace_text`
535+- [x] `/reload-ext` slash command (hot-reload without restarting zot)
536+
452 Future (no firm timeline):537 Future (no firm timeline):
453-- [ ] interception for additional events beyond `tool_call`538+- [ ] TypeScript and Python SDK packages (currently the wire format
454-- [ ] modify (not just block) tool args mid-flight539+ is stable enough to hand-roll, see the Python quick-start)
455-- [ ] `/reload-ext` slash command (hot-reload without restarting zot)540+- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
541+- [ ] per-extension permission scopes (today: full user privileges)
M internal/agent/cli.go +38 -2
@@ -3,6 +3,7 @@ package agent
33 import (
44 "bufio"
55 "context"
6+ "encoding/json"
67 "fmt"
78 "io"
89 "os"
@@ -288,8 +289,23 @@ func runInteractive(ctx context.Context, args Args, version string) error {
288289 if a == nil {
289290 return a
290291 }
291- a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {
292- return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
292+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
293+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
294+ if r.Block {
295+ return false, r.Reason, nil
296+ }
297+ return true, "", r.ModifiedArgs
298+ }
299+ a.BeforeTurn = func(step int) (bool, string) {
300+ r := extMgr.InterceptTurnStart(ctx, step)
301+ return !r.Block, r.Reason
302+ }
303+ a.BeforeAssistantMessage = func(text string) (bool, string, string) {
304+ r := extMgr.InterceptAssistantMessage(ctx, text)
305+ if r.Block {
306+ return false, r.Reason, ""
307+ }
308+ return true, "", r.ReplaceText
293309 }
294310 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
295311 return a
@@ -328,6 +344,26 @@ func runInteractive(ctx context.Context, args Args, version string) error {
328344 ag = wireAgentExt(r.NewAgent())
329345 }
330346
347+ // /reload-ext callback: after the manager has respawned every
348+ // extension, re-resolve the tool registry (built-ins + freshly-
349+ // registered extension tools) and swap it onto the current
350+ // agent in-place. The current agent may have been replaced by a
351+ // /model swap since spawn, so re-read the live `ag` on each
352+ // invocation.
353+ extMgr.SetOnReload(func() {
354+ current := ag
355+ if current == nil {
356+ return
357+ }
358+ resolved, err := Resolve(args, true)
359+ if err != nil {
360+ return
361+ }
362+ resolved.UseSandbox(sharedSandbox)
363+ resolved.MergeExtensionTools(extToolAdapter)
364+ current.SetTools(resolved.ToolRegistry)
365+ })
366+
331367 // Fire session_start once we know the manager's running.
332368 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
333369
@@ -3,6 +3,7 @@ package agent
3 import (3 import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6+ "encoding/json"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "os"9 "os"
@@ -288,8 +289,23 @@ func runInteractive(ctx context.Context, args Args, version string) error {
288 if a == nil {289 if a == nil {
289 return a290 return a
290 }291 }
291- a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {292+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
292- return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)293+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
294+ if r.Block {
295+ return false, r.Reason, nil
296+ }
297+ return true, "", r.ModifiedArgs
298+ }
299+ a.BeforeTurn = func(step int) (bool, string) {
300+ r := extMgr.InterceptTurnStart(ctx, step)
301+ return !r.Block, r.Reason
302+ }
303+ a.BeforeAssistantMessage = func(text string) (bool, string, string) {
304+ r := extMgr.InterceptAssistantMessage(ctx, text)
305+ if r.Block {
306+ return false, r.Reason, ""
307+ }
308+ return true, "", r.ReplaceText
293 }309 }
294 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }310 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
295 return a311 return a
@@ -328,6 +344,26 @@ func runInteractive(ctx context.Context, args Args, version string) error {
328 ag = wireAgentExt(r.NewAgent())344 ag = wireAgentExt(r.NewAgent())
329 }345 }
330 346
347+ // /reload-ext callback: after the manager has respawned every
348+ // extension, re-resolve the tool registry (built-ins + freshly-
349+ // registered extension tools) and swap it onto the current
350+ // agent in-place. The current agent may have been replaced by a
351+ // /model swap since spawn, so re-read the live `ag` on each
352+ // invocation.
353+ extMgr.SetOnReload(func() {
354+ current := ag
355+ if current == nil {
356+ return
357+ }
358+ resolved, err := Resolve(args, true)
359+ if err != nil {
360+ return
361+ }
362+ resolved.UseSandbox(sharedSandbox)
363+ resolved.MergeExtensionTools(extToolAdapter)
364+ current.SetTools(resolved.ToolRegistry)
365+ })
366+
331 // Fire session_start once we know the manager's running.367 // Fire session_start once we know the manager's running.
332 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})368 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
333 369
M internal/agent/extensions/events.go +153 -38
@@ -46,77 +46,192 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
4646 }
4747 }
4848
49-// InterceptToolCall asks every extension that subscribed to tool_call
50-// interception whether the call may proceed. Subscribers are invoked
51-// serially; the first one to return Block: true wins, with its Reason
52-// surfaced as the refusal text. allowed=true means proceed.
53-//
54-// Each subscriber gets up to interceptTimeout to reply; missing the
55-// deadline counts as "allow" (i.e. an unresponsive interceptor never
56-// stalls the agent).
57-func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {
58- m.mu.RLock()
59- subs := make([]*Extension, 0, len(m.ext))
60- for _, ext := range m.ext {
61- ext.mu.Lock()
62- _, subscribed := ext.interceptSubs["tool_call"]
63- ext.mu.Unlock()
64- if subscribed {
65- subs = append(subs, ext)
49+// InterceptResult aggregates the outcome of walking every subscribed
50+// interceptor for one event. The zero value means "allow, no
51+// modification". Callers check Block first; if allowed, use the
52+// optional rewrite fields (ModifiedArgs for tool_call, ReplaceText
53+// for assistant_message) to carry the rewrite into the action.
54+type InterceptResult struct {
55+ Block bool
56+ Reason string
57+ ModifiedArgs json.RawMessage
58+ ReplaceText string
59+}
60+
61+const interceptTimeout = 5 * time.Second
62+
63+// InterceptToolCall is the typed entry point for tool_call
64+// interception. Subscribers are invoked serially; the first to return
65+// Block=true wins. Rewrites (ModifiedArgs) from earlier subscribers
66+// flow into later ones, so a chain of guards can successively redact
67+// / patch the args.
68+func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) InterceptResult {
69+ subs := m.interceptSubsFor("tool_call")
70+ if len(subs) == 0 {
71+ return InterceptResult{}
72+ }
73+ current := args
74+ for _, ext := range subs {
75+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
76+ Event: "tool_call",
77+ ToolID: toolID,
78+ ToolName: toolName,
79+ ToolArgs: current,
80+ })
81+ if r.Block {
82+ return r
83+ }
84+ if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
85+ current = r.ModifiedArgs
6686 }
6787 }
68- m.mu.RUnlock()
88+ out := InterceptResult{}
89+ if !jsonEqual(current, args) {
90+ out.ModifiedArgs = current
91+ }
92+ return out
93+}
94+
95+// InterceptTurnStart asks every subscriber whether the upcoming turn
96+// may run. Block=true aborts the turn with Reason shown to the user.
97+// Rewrites are not supported for this event.
98+func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptResult {
99+ subs := m.interceptSubsFor("turn_start")
69100 if len(subs) == 0 {
70- return true, ""
101+ return InterceptResult{}
71102 }
103+ for _, ext := range subs {
104+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
105+ Event: "turn_start",
106+ Step: step,
107+ })
108+ if r.Block {
109+ return r
110+ }
111+ }
112+ return InterceptResult{}
113+}
72114
115+// InterceptAssistantMessage asks every subscriber to approve, block,
116+// or rewrite the assistant's final visible text. Block=true hides the
117+// message from the user entirely; a non-empty ReplaceText rewrites
118+// what the user sees while keeping the model's original text in the
119+// transcript. Successive rewrites chain: each subscriber sees the
120+// previous subscriber's output.
121+func (m *Manager) InterceptAssistantMessage(ctx context.Context, text string) InterceptResult {
122+ subs := m.interceptSubsFor("assistant_message")
123+ if len(subs) == 0 {
124+ return InterceptResult{}
125+ }
126+ current := text
73127 for _, ext := range subs {
74- ok, reason := m.askIntercept(ctx, ext, toolID, toolName, args)
75- if !ok {
76- return false, reason
128+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
129+ Event: "assistant_message",
130+ Text: current,
131+ })
132+ if r.Block {
133+ return r
77134 }
135+ if r.ReplaceText != "" {
136+ current = r.ReplaceText
137+ }
138+ }
139+ out := InterceptResult{}
140+ if current != text {
141+ out.ReplaceText = current
78142 }
79- return true, ""
143+ return out
80144 }
81145
82-const interceptTimeout = 5 * time.Second
146+// interceptSubsFor returns the snapshot of extensions that subscribed
147+// to intercepting the named event.
148+func (m *Manager) interceptSubsFor(event string) []*Extension {
149+ m.mu.RLock()
150+ defer m.mu.RUnlock()
151+ subs := make([]*Extension, 0, len(m.ext))
152+ for _, ext := range m.ext {
153+ ext.mu.Lock()
154+ _, subscribed := ext.interceptSubs[event]
155+ ext.mu.Unlock()
156+ if subscribed {
157+ subs = append(subs, ext)
158+ }
159+ }
160+ return subs
161+}
83162
84-func (m *Manager) askIntercept(ctx context.Context, ext *Extension, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {
163+// askIntercept sends one EventInterceptFromHost to ext and waits for
164+// the reply, a timeout, or context cancellation. Returns a typed
165+// result. Never blocks for longer than interceptTimeout.
166+func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult {
85167 id := newCorrelationID()
86168 ch := make(chan extproto.EventInterceptResponseFromExt, 1)
87169 ext.mu.Lock()
88170 ext.pendingIntercept[id] = ch
89171 ext.mu.Unlock()
90172
91- frame, _ := extproto.Encode(extproto.EventInterceptFromHost{
92- Type: "event_intercept",
93- ID: id,
94- Event: "tool_call",
95- ToolID: toolID,
96- ToolName: toolName,
97- ToolArgs: args,
98- })
173+ payload.Type = "event_intercept"
174+ payload.ID = id
175+ frame, err := extproto.Encode(payload)
176+ if err != nil {
177+ ext.mu.Lock()
178+ delete(ext.pendingIntercept, id)
179+ ext.mu.Unlock()
180+ return InterceptResult{}
181+ }
99182 if _, err := ext.stdin.Write(frame); err != nil {
100183 ext.mu.Lock()
101184 delete(ext.pendingIntercept, id)
102185 ext.mu.Unlock()
103186 fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err)
104- return true, ""
187+ return InterceptResult{}
105188 }
106189
107190 select {
108191 case resp := <-ch:
109- return !resp.Block, resp.Reason
192+ return InterceptResult{
193+ Block: resp.Block,
194+ Reason: resp.Reason,
195+ ModifiedArgs: resp.ModifiedArgs,
196+ ReplaceText: resp.ReplaceText,
197+ }
110198 case <-time.After(interceptTimeout):
111199 ext.mu.Lock()
112200 delete(ext.pendingIntercept, id)
113201 ext.mu.Unlock()
114- fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", toolName)
115- return true, ""
202+ fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event)
203+ return InterceptResult{}
116204 case <-ctx.Done():
117205 ext.mu.Lock()
118206 delete(ext.pendingIntercept, id)
119207 ext.mu.Unlock()
120- return true, ""
208+ return InterceptResult{}
209+ }
210+}
211+
212+// jsonEqual reports whether a and b encode the same JSON value. Used
213+// to detect whether the interceptor chain actually mutated the args.
214+func jsonEqual(a, b json.RawMessage) bool {
215+ if len(a) == len(b) {
216+ same := true
217+ for i := range a {
218+ if a[i] != b[i] {
219+ same = false
220+ break
221+ }
222+ }
223+ if same {
224+ return true
225+ }
226+ }
227+ // Fallback to structural compare so whitespace differences don't
228+ // register as a mutation. (We don't actually rely on this to be
229+ // cheap, callers only use it to pick a code path.)
230+ var av, bv any
231+ if json.Unmarshal(a, &av) != nil || json.Unmarshal(b, &bv) != nil {
232+ return false
121233 }
234+ ae, _ := json.Marshal(av)
235+ be, _ := json.Marshal(bv)
236+ return string(ae) == string(be)
122237 }
@@ -46,77 +46,192 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
46 }46 }
47 }47 }
48 48
49-// InterceptToolCall asks every extension that subscribed to tool_call49+// InterceptResult aggregates the outcome of walking every subscribed
50-// interception whether the call may proceed. Subscribers are invoked50+// interceptor for one event. The zero value means "allow, no
51-// serially; the first one to return Block: true wins, with its Reason51+// modification". Callers check Block first; if allowed, use the
52-// surfaced as the refusal text. allowed=true means proceed.52+// optional rewrite fields (ModifiedArgs for tool_call, ReplaceText
53-//53+// for assistant_message) to carry the rewrite into the action.
54-// Each subscriber gets up to interceptTimeout to reply; missing the54+type InterceptResult struct {
55-// deadline counts as "allow" (i.e. an unresponsive interceptor never55+ Block bool
56-// stalls the agent).56+ Reason string
57-func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {57+ ModifiedArgs json.RawMessage
58- m.mu.RLock()58+ ReplaceText string
59- subs := make([]*Extension, 0, len(m.ext))59+}
60- for _, ext := range m.ext {60+
61- ext.mu.Lock()61+const interceptTimeout = 5 * time.Second
62- _, subscribed := ext.interceptSubs["tool_call"]62+
63- ext.mu.Unlock()63+// InterceptToolCall is the typed entry point for tool_call
64- if subscribed {64+// interception. Subscribers are invoked serially; the first to return
65- subs = append(subs, ext)65+// Block=true wins. Rewrites (ModifiedArgs) from earlier subscribers
66+// flow into later ones, so a chain of guards can successively redact
67+// / patch the args.
68+func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) InterceptResult {
69+ subs := m.interceptSubsFor("tool_call")
70+ if len(subs) == 0 {
71+ return InterceptResult{}
72+ }
73+ current := args
74+ for _, ext := range subs {
75+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
76+ Event: "tool_call",
77+ ToolID: toolID,
78+ ToolName: toolName,
79+ ToolArgs: current,
80+ })
81+ if r.Block {
82+ return r
83+ }
84+ if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
85+ current = r.ModifiedArgs
66 }86 }
67 }87 }
68- m.mu.RUnlock()88+ out := InterceptResult{}
89+ if !jsonEqual(current, args) {
90+ out.ModifiedArgs = current
91+ }
92+ return out
93+}
94+
95+// InterceptTurnStart asks every subscriber whether the upcoming turn
96+// may run. Block=true aborts the turn with Reason shown to the user.
97+// Rewrites are not supported for this event.
98+func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptResult {
99+ subs := m.interceptSubsFor("turn_start")
69 if len(subs) == 0 {100 if len(subs) == 0 {
70- return true, ""101+ return InterceptResult{}
71 }102 }
103+ for _, ext := range subs {
104+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
105+ Event: "turn_start",
106+ Step: step,
107+ })
108+ if r.Block {
109+ return r
110+ }
111+ }
112+ return InterceptResult{}
113+}
72 114
115+// InterceptAssistantMessage asks every subscriber to approve, block,
116+// or rewrite the assistant's final visible text. Block=true hides the
117+// message from the user entirely; a non-empty ReplaceText rewrites
118+// what the user sees while keeping the model's original text in the
119+// transcript. Successive rewrites chain: each subscriber sees the
120+// previous subscriber's output.
121+func (m *Manager) InterceptAssistantMessage(ctx context.Context, text string) InterceptResult {
122+ subs := m.interceptSubsFor("assistant_message")
123+ if len(subs) == 0 {
124+ return InterceptResult{}
125+ }
126+ current := text
73 for _, ext := range subs {127 for _, ext := range subs {
74- ok, reason := m.askIntercept(ctx, ext, toolID, toolName, args)128+ r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
75- if !ok {129+ Event: "assistant_message",
76- return false, reason130+ Text: current,
131+ })
132+ if r.Block {
133+ return r
77 }134 }
135+ if r.ReplaceText != "" {
136+ current = r.ReplaceText
137+ }
138+ }
139+ out := InterceptResult{}
140+ if current != text {
141+ out.ReplaceText = current
78 }142 }
79- return true, ""143+ return out
80 }144 }
81 145
82-const interceptTimeout = 5 * time.Second146+// interceptSubsFor returns the snapshot of extensions that subscribed
147+// to intercepting the named event.
148+func (m *Manager) interceptSubsFor(event string) []*Extension {
149+ m.mu.RLock()
150+ defer m.mu.RUnlock()
151+ subs := make([]*Extension, 0, len(m.ext))
152+ for _, ext := range m.ext {
153+ ext.mu.Lock()
154+ _, subscribed := ext.interceptSubs[event]
155+ ext.mu.Unlock()
156+ if subscribed {
157+ subs = append(subs, ext)
158+ }
159+ }
160+ return subs
161+}
83 162
84-func (m *Manager) askIntercept(ctx context.Context, ext *Extension, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {163+// askIntercept sends one EventInterceptFromHost to ext and waits for
164+// the reply, a timeout, or context cancellation. Returns a typed
165+// result. Never blocks for longer than interceptTimeout.
166+func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult {
85 id := newCorrelationID()167 id := newCorrelationID()
86 ch := make(chan extproto.EventInterceptResponseFromExt, 1)168 ch := make(chan extproto.EventInterceptResponseFromExt, 1)
87 ext.mu.Lock()169 ext.mu.Lock()
88 ext.pendingIntercept[id] = ch170 ext.pendingIntercept[id] = ch
89 ext.mu.Unlock()171 ext.mu.Unlock()
90 172
91- frame, _ := extproto.Encode(extproto.EventInterceptFromHost{173+ payload.Type = "event_intercept"
92- Type: "event_intercept",174+ payload.ID = id
93- ID: id,175+ frame, err := extproto.Encode(payload)
94- Event: "tool_call",176+ if err != nil {
95- ToolID: toolID,177+ ext.mu.Lock()
96- ToolName: toolName,178+ delete(ext.pendingIntercept, id)
97- ToolArgs: args,179+ ext.mu.Unlock()
98- })180+ return InterceptResult{}
181+ }
99 if _, err := ext.stdin.Write(frame); err != nil {182 if _, err := ext.stdin.Write(frame); err != nil {
100 ext.mu.Lock()183 ext.mu.Lock()
101 delete(ext.pendingIntercept, id)184 delete(ext.pendingIntercept, id)
102 ext.mu.Unlock()185 ext.mu.Unlock()
103 fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err)186 fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err)
104- return true, ""187+ return InterceptResult{}
105 }188 }
106 189
107 select {190 select {
108 case resp := <-ch:191 case resp := <-ch:
109- return !resp.Block, resp.Reason192+ return InterceptResult{
193+ Block: resp.Block,
194+ Reason: resp.Reason,
195+ ModifiedArgs: resp.ModifiedArgs,
196+ ReplaceText: resp.ReplaceText,
197+ }
110 case <-time.After(interceptTimeout):198 case <-time.After(interceptTimeout):
111 ext.mu.Lock()199 ext.mu.Lock()
112 delete(ext.pendingIntercept, id)200 delete(ext.pendingIntercept, id)
113 ext.mu.Unlock()201 ext.mu.Unlock()
114- fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", toolName)202+ fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event)
115- return true, ""203+ return InterceptResult{}
116 case <-ctx.Done():204 case <-ctx.Done():
117 ext.mu.Lock()205 ext.mu.Lock()
118 delete(ext.pendingIntercept, id)206 delete(ext.pendingIntercept, id)
119 ext.mu.Unlock()207 ext.mu.Unlock()
120- return true, ""208+ return InterceptResult{}
209+ }
210+}
211+
212+// jsonEqual reports whether a and b encode the same JSON value. Used
213+// to detect whether the interceptor chain actually mutated the args.
214+func jsonEqual(a, b json.RawMessage) bool {
215+ if len(a) == len(b) {
216+ same := true
217+ for i := range a {
218+ if a[i] != b[i] {
219+ same = false
220+ break
221+ }
222+ }
223+ if same {
224+ return true
225+ }
226+ }
227+ // Fallback to structural compare so whitespace differences don't
228+ // register as a mutation. (We don't actually rely on this to be
229+ // cheap, callers only use it to pick a code path.)
230+ var av, bv any
231+ if json.Unmarshal(a, &av) != nil || json.Unmarshal(b, &bv) != nil {
232+ return false
121 }233 }
234+ ae, _ := json.Marshal(av)
235+ be, _ := json.Marshal(bv)
236+ return string(ae) == string(be)
122 }237 }
A internal/agent/extensions/intercept_test.go +196 -0
@@ -0,0 +1,196 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// TestInterceptAllThreeEvents exercises tool_call / turn_start /
14+// assistant_message interception end-to-end via a bash extension that
15+// blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,
16+// suppresses nothing at turn_start, and rewrites SECRET → [redacted]
17+// in assistant messages.
18+//
19+// Validates: block, modified_args, pass-through, and replace_text.
20+func TestInterceptAllThreeEvents(t *testing.T) {
21+ if _, err := os.Stat("/bin/bash"); err != nil {
22+ t.Skip("no /bin/bash")
23+ }
24+
25+ extDir := t.TempDir()
26+ script := `#!/bin/bash
27+emit() { printf '%s\n' "$1"; }
28+emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}'
29+emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'
30+emit '{"type":"ready"}'
31+while IFS= read -r line; do
32+ t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
33+ id=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
34+ ev=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("event",""))')
35+ if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
36+ if [[ "$t" != "event_intercept" ]]; then continue; fi
37+ case "$ev" in
38+ tool_call)
39+ name=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))')
40+ args=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("tool_args",{})))')
41+ cmd=$(printf '%s' "$args" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("command",""))')
42+ if [[ "$name" == "bash" && "$cmd" == *"rm -rf"* ]]; then
43+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}"
44+ elif [[ "$name" == "bash" && -n "$cmd" ]]; then
45+ new=$(python3 -c "import json,sys;print(json.dumps({'command':'echo GUARDED: '+sys.argv[1]}))" "$cmd")
46+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"modified_args\":$new}"
47+ else
48+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
49+ fi ;;
50+ turn_start)
51+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" ;;
52+ assistant_message)
53+ text=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("text",""))')
54+ if [[ "$text" == *"SECRET"* ]]; then
55+ new=$(python3 -c "import sys;print(sys.argv[1].replace('SECRET','[redacted]'))" "$text")
56+ rt=$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$new")
57+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"replace_text\":$rt}"
58+ else
59+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
60+ fi ;;
61+ esac
62+done
63+`
64+ if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
65+ t.Fatal(err)
66+ }
67+ manifest := `{"name":"itest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
68+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+
72+ // ZotHome is unused here; we load the extension explicitly.
73+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
74+ t.Cleanup(func() { m.Stop(2 * time.Second) })
75+
76+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
77+ defer cancel()
78+ if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
79+ t.Fatalf("LoadExplicit: %v", errs)
80+ }
81+ m.WaitForReady(3 * time.Second)
82+
83+ // tool_call: rm -rf is blocked
84+ res := m.InterceptToolCall(ctx, "T1", "bash", json.RawMessage(`{"command":"rm -rf /tmp/foo"}`))
85+ if !res.Block || !strings.Contains(res.Reason, "refused") {
86+ t.Errorf("rm -rf: want block+reason, got %+v", res)
87+ }
88+
89+ // tool_call: non-dangerous bash gets args rewritten
90+ res = m.InterceptToolCall(ctx, "T2", "bash", json.RawMessage(`{"command":"ls -la"}`))
91+ if res.Block {
92+ t.Errorf("ls -la: want allow, got block %q", res.Reason)
93+ }
94+ if len(res.ModifiedArgs) == 0 {
95+ t.Errorf("ls -la: want modified_args, got nothing")
96+ } else {
97+ var obj map[string]string
98+ if err := json.Unmarshal(res.ModifiedArgs, &obj); err != nil {
99+ t.Errorf("modified_args: %v", err)
100+ } else if !strings.HasPrefix(obj["command"], "echo GUARDED") {
101+ t.Errorf("modified_args command=%q, want echo GUARDED prefix", obj["command"])
102+ }
103+ }
104+
105+ // tool_call: non-bash tool untouched
106+ res = m.InterceptToolCall(ctx, "T3", "read", json.RawMessage(`{"path":"/etc/hosts"}`))
107+ if res.Block {
108+ t.Errorf("read: want allow, got block %q", res.Reason)
109+ }
110+ if len(res.ModifiedArgs) != 0 {
111+ t.Errorf("read: want no modified_args, got %s", string(res.ModifiedArgs))
112+ }
113+
114+ // turn_start: always allowed
115+ if r := m.InterceptTurnStart(ctx, 1); r.Block {
116+ t.Errorf("turn_start: want allow, got block %q", r.Reason)
117+ }
118+
119+ // assistant_message: SECRET gets redacted
120+ r := m.InterceptAssistantMessage(ctx, "your password is SECRET123")
121+ if r.Block {
122+ t.Errorf("msg: want allow, got block %q", r.Reason)
123+ }
124+ if !strings.Contains(r.ReplaceText, "[redacted]") {
125+ t.Errorf("msg: want [redacted] in replacement, got %q", r.ReplaceText)
126+ }
127+
128+ // assistant_message: clean text unchanged
129+ r = m.InterceptAssistantMessage(ctx, "hello world")
130+ if r.Block {
131+ t.Errorf("clean: want allow")
132+ }
133+ if r.ReplaceText != "" {
134+ t.Errorf("clean: want no replace, got %q", r.ReplaceText)
135+ }
136+}
137+
138+// TestReloadRespawnsExtensions verifies Reload tears down and brings
139+// back the same extension, and the onReload callback fires.
140+func TestReloadRespawnsExtensions(t *testing.T) {
141+ if _, err := os.Stat("/bin/bash"); err != nil {
142+ t.Skip("no /bin/bash")
143+ }
144+
145+ extDir := t.TempDir()
146+ script := `#!/bin/bash
147+emit() { printf '%s\n' "$1"; }
148+emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}'
149+emit '{"type":"register_command","name":"rtest","description":"test"}'
150+emit '{"type":"ready"}'
151+while IFS= read -r line; do
152+ t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
153+ if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
154+done
155+`
156+ if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
157+ t.Fatal(err)
158+ }
159+ manifest := `{"name":"rtest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
160+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
161+ t.Fatal(err)
162+ }
163+
164+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
165+ t.Cleanup(func() { m.Stop(2 * time.Second) })
166+
167+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
168+ defer cancel()
169+ if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
170+ t.Fatalf("LoadExplicit: %v", errs)
171+ }
172+ m.WaitForReady(3 * time.Second)
173+ before := len(m.All())
174+ if before == 0 {
175+ t.Fatal("extension didn't load")
176+ }
177+
178+ reloadFired := false
179+ m.SetOnReload(func() { reloadFired = true })
180+
181+ stats := m.Reload(ctx, 2*time.Second)
182+ if stats.Stopped != before {
183+ t.Errorf("Stopped: want %d, got %d", before, stats.Stopped)
184+ }
185+ if stats.Loaded != before {
186+ t.Errorf("Loaded: want %d, got %d", before, stats.Loaded)
187+ }
188+ if !reloadFired {
189+ t.Error("onReload callback didn't fire")
190+ }
191+
192+ // Registered command should still be there after reload.
193+ if !m.HasCommand("rtest") {
194+ t.Error("rtest command not re-registered after reload")
195+ }
196+}
@@ -0,0 +1,196 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// TestInterceptAllThreeEvents exercises tool_call / turn_start /
14+// assistant_message interception end-to-end via a bash extension that
15+// blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,
16+// suppresses nothing at turn_start, and rewrites SECRET → [redacted]
17+// in assistant messages.
18+//
19+// Validates: block, modified_args, pass-through, and replace_text.
20+func TestInterceptAllThreeEvents(t *testing.T) {
21+ if _, err := os.Stat("/bin/bash"); err != nil {
22+ t.Skip("no /bin/bash")
23+ }
24+
25+ extDir := t.TempDir()
26+ script := `#!/bin/bash
27+emit() { printf '%s\n' "$1"; }
28+emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}'
29+emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'
30+emit '{"type":"ready"}'
31+while IFS= read -r line; do
32+ t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
33+ id=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
34+ ev=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("event",""))')
35+ if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
36+ if [[ "$t" != "event_intercept" ]]; then continue; fi
37+ case "$ev" in
38+ tool_call)
39+ name=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))')
40+ args=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("tool_args",{})))')
41+ cmd=$(printf '%s' "$args" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("command",""))')
42+ if [[ "$name" == "bash" && "$cmd" == *"rm -rf"* ]]; then
43+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}"
44+ elif [[ "$name" == "bash" && -n "$cmd" ]]; then
45+ new=$(python3 -c "import json,sys;print(json.dumps({'command':'echo GUARDED: '+sys.argv[1]}))" "$cmd")
46+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"modified_args\":$new}"
47+ else
48+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
49+ fi ;;
50+ turn_start)
51+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" ;;
52+ assistant_message)
53+ text=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("text",""))')
54+ if [[ "$text" == *"SECRET"* ]]; then
55+ new=$(python3 -c "import sys;print(sys.argv[1].replace('SECRET','[redacted]'))" "$text")
56+ rt=$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$new")
57+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"replace_text\":$rt}"
58+ else
59+ emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
60+ fi ;;
61+ esac
62+done
63+`
64+ if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
65+ t.Fatal(err)
66+ }
67+ manifest := `{"name":"itest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
68+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+
72+ // ZotHome is unused here; we load the extension explicitly.
73+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
74+ t.Cleanup(func() { m.Stop(2 * time.Second) })
75+
76+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
77+ defer cancel()
78+ if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
79+ t.Fatalf("LoadExplicit: %v", errs)
80+ }
81+ m.WaitForReady(3 * time.Second)
82+
83+ // tool_call: rm -rf is blocked
84+ res := m.InterceptToolCall(ctx, "T1", "bash", json.RawMessage(`{"command":"rm -rf /tmp/foo"}`))
85+ if !res.Block || !strings.Contains(res.Reason, "refused") {
86+ t.Errorf("rm -rf: want block+reason, got %+v", res)
87+ }
88+
89+ // tool_call: non-dangerous bash gets args rewritten
90+ res = m.InterceptToolCall(ctx, "T2", "bash", json.RawMessage(`{"command":"ls -la"}`))
91+ if res.Block {
92+ t.Errorf("ls -la: want allow, got block %q", res.Reason)
93+ }
94+ if len(res.ModifiedArgs) == 0 {
95+ t.Errorf("ls -la: want modified_args, got nothing")
96+ } else {
97+ var obj map[string]string
98+ if err := json.Unmarshal(res.ModifiedArgs, &obj); err != nil {
99+ t.Errorf("modified_args: %v", err)
100+ } else if !strings.HasPrefix(obj["command"], "echo GUARDED") {
101+ t.Errorf("modified_args command=%q, want echo GUARDED prefix", obj["command"])
102+ }
103+ }
104+
105+ // tool_call: non-bash tool untouched
106+ res = m.InterceptToolCall(ctx, "T3", "read", json.RawMessage(`{"path":"/etc/hosts"}`))
107+ if res.Block {
108+ t.Errorf("read: want allow, got block %q", res.Reason)
109+ }
110+ if len(res.ModifiedArgs) != 0 {
111+ t.Errorf("read: want no modified_args, got %s", string(res.ModifiedArgs))
112+ }
113+
114+ // turn_start: always allowed
115+ if r := m.InterceptTurnStart(ctx, 1); r.Block {
116+ t.Errorf("turn_start: want allow, got block %q", r.Reason)
117+ }
118+
119+ // assistant_message: SECRET gets redacted
120+ r := m.InterceptAssistantMessage(ctx, "your password is SECRET123")
121+ if r.Block {
122+ t.Errorf("msg: want allow, got block %q", r.Reason)
123+ }
124+ if !strings.Contains(r.ReplaceText, "[redacted]") {
125+ t.Errorf("msg: want [redacted] in replacement, got %q", r.ReplaceText)
126+ }
127+
128+ // assistant_message: clean text unchanged
129+ r = m.InterceptAssistantMessage(ctx, "hello world")
130+ if r.Block {
131+ t.Errorf("clean: want allow")
132+ }
133+ if r.ReplaceText != "" {
134+ t.Errorf("clean: want no replace, got %q", r.ReplaceText)
135+ }
136+}
137+
138+// TestReloadRespawnsExtensions verifies Reload tears down and brings
139+// back the same extension, and the onReload callback fires.
140+func TestReloadRespawnsExtensions(t *testing.T) {
141+ if _, err := os.Stat("/bin/bash"); err != nil {
142+ t.Skip("no /bin/bash")
143+ }
144+
145+ extDir := t.TempDir()
146+ script := `#!/bin/bash
147+emit() { printf '%s\n' "$1"; }
148+emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}'
149+emit '{"type":"register_command","name":"rtest","description":"test"}'
150+emit '{"type":"ready"}'
151+while IFS= read -r line; do
152+ t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
153+ if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
154+done
155+`
156+ if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
157+ t.Fatal(err)
158+ }
159+ manifest := `{"name":"rtest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
160+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
161+ t.Fatal(err)
162+ }
163+
164+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
165+ t.Cleanup(func() { m.Stop(2 * time.Second) })
166+
167+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
168+ defer cancel()
169+ if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
170+ t.Fatalf("LoadExplicit: %v", errs)
171+ }
172+ m.WaitForReady(3 * time.Second)
173+ before := len(m.All())
174+ if before == 0 {
175+ t.Fatal("extension didn't load")
176+ }
177+
178+ reloadFired := false
179+ m.SetOnReload(func() { reloadFired = true })
180+
181+ stats := m.Reload(ctx, 2*time.Second)
182+ if stats.Stopped != before {
183+ t.Errorf("Stopped: want %d, got %d", before, stats.Stopped)
184+ }
185+ if stats.Loaded != before {
186+ t.Errorf("Loaded: want %d, got %d", before, stats.Loaded)
187+ }
188+ if !reloadFired {
189+ t.Error("onReload callback didn't fire")
190+ }
191+
192+ // Registered command should still be there after reload.
193+ if !m.HasCommand("rtest") {
194+ t.Error("rtest command not re-registered after reload")
195+ }
196+}
M internal/agent/extensions/manager.go +130 -1
@@ -132,6 +132,15 @@ type Manager struct {
132132 // toolIndex maps an extension-defined tool name to its owning
133133 // extension. Same first-come-first-served rule as commandIndex.
134134 toolIndex map[string]*Extension
135+
136+ // explicitPaths remembers ad-hoc paths passed via --ext so
137+ // Reload can respawn them alongside the discovered set.
138+ explicitPaths []string
139+
140+ // onReload, if set, is invoked after a successful Reload. Used
141+ // by the host so it can rebuild the agent's tool registry with
142+ // the freshly-registered extension tools.
143+ onReload func()
135144 }
136145
137146 // New constructs an empty Manager. Call Discover to populate it from
@@ -283,12 +292,14 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
283292
284293 var wg sync.WaitGroup
285294 errCh := make(chan error, len(paths))
295+ absPaths := make([]string, 0, len(paths))
286296 for _, p := range paths {
287297 abs, err := filepath.Abs(p)
288298 if err != nil {
289299 errCh <- fmt.Errorf("%s: %w", p, err)
290300 continue
291301 }
302+ absPaths = append(absPaths, abs)
292303 wg.Add(1)
293304 go func(extDir string) {
294305 defer wg.Done()
@@ -300,6 +311,10 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
300311 wg.Wait()
301312 close(errCh)
302313
314+ m.mu.Lock()
315+ m.explicitPaths = append(m.explicitPaths, absPaths...)
316+ m.mu.Unlock()
317+
303318 var errs []error
304319 for e := range errCh {
305320 errs = append(errs, e)
@@ -307,6 +322,119 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
307322 return errs
308323 }
309324
325+// SetOnReload registers a callback fired after a successful Reload.
326+// Hosts use it to rebuild the agent's tool registry with freshly-
327+// registered extension tools.
328+func (m *Manager) SetOnReload(fn func()) {
329+ m.mu.Lock()
330+ m.onReload = fn
331+ m.mu.Unlock()
332+}
333+
334+// ReloadStats summarises the outcome of Reload.
335+type ReloadStats struct {
336+ Stopped int // how many old processes were torn down
337+ Loaded int // how many new processes reached spawn
338+ Ready int // how many of those signalled ready in time
339+ Errors []error // non-fatal per-extension errors
340+}
341+
342+// Reload tears down every running extension, re-reads the manifests
343+// from disk, respawns everyone (including the --ext paths remembered
344+// from LoadExplicit), waits up to grace for ready signals, and
345+// invokes the SetOnReload callback so the host can rebuild its tool
346+// registry. The manager's internal maps are cleared before the
347+// new load to ensure a clean slate.
348+//
349+// Safe to call concurrently with normal host operations: the lock is
350+// released between stop and respawn so pending InvokeTool / Invoke
351+// calls on the old processes get a clean error as their stdin
352+// closes.
353+func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
354+ stats := ReloadStats{}
355+
356+ // Snapshot and remember the explicit paths before we wipe state.
357+ m.mu.Lock()
358+ old := m.ext
359+ explicit := append([]string(nil), m.explicitPaths...)
360+ stats.Stopped = len(old)
361+ m.ext = map[string]*Extension{}
362+ m.commandIndex = map[string]*Extension{}
363+ m.toolIndex = map[string]*Extension{}
364+ m.explicitPaths = nil
365+ callback := m.onReload
366+ m.mu.Unlock()
367+
368+ // Graceful stop of the old set (reuses Stop's shutdown logic,
369+ // but Stop re-reads m.ext which is now empty, so we replicate
370+ // the small shutdown loop here on the snapshot).
371+ for _, ext := range old {
372+ if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
373+ _, _ = ext.stdin.Write(frame)
374+ }
375+ _ = ext.stdin.Close()
376+ }
377+ deadline := time.Now().Add(grace)
378+ for _, ext := range old {
379+ remaining := time.Until(deadline)
380+ if remaining <= 0 {
381+ remaining = 100 * time.Millisecond
382+ }
383+ done := make(chan struct{})
384+ go func(e *Extension) { _ = e.cmd.Wait(); close(done) }(ext)
385+ select {
386+ case <-done:
387+ case <-time.After(remaining):
388+ _ = ext.cmd.Process.Signal(syscall.SIGTERM)
389+ select {
390+ case <-done:
391+ case <-time.After(time.Second):
392+ _ = ext.cmd.Process.Kill()
393+ <-done
394+ }
395+ }
396+ if ext.logFile != nil {
397+ _ = ext.logFile.Close()
398+ }
399+ }
400+
401+ // Fresh load. Explicit paths first (they still win on conflict).
402+ if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
403+ stats.Errors = append(stats.Errors, errs...)
404+ }
405+ if errs := m.Discover(ctx); len(errs) > 0 {
406+ stats.Errors = append(stats.Errors, errs...)
407+ }
408+
409+ m.mu.RLock()
410+ stats.Loaded = len(m.ext)
411+ m.mu.RUnlock()
412+
413+ // Wait for ready frames. Use the same 3s grace zot uses at
414+ // startup so the reload feels no slower than a cold boot.
415+ readyDeadline := time.Now().Add(grace)
416+ if time.Until(readyDeadline) < 3*time.Second {
417+ readyDeadline = time.Now().Add(3 * time.Second)
418+ }
419+ m.WaitForReady(time.Until(readyDeadline))
420+
421+ m.mu.RLock()
422+ for _, ext := range m.ext {
423+ select {
424+ case <-ext.readyCh:
425+ stats.Ready++
426+ default:
427+ }
428+ }
429+ m.mu.RUnlock()
430+
431+ if callback != nil {
432+ callback()
433+ }
434+
435+ return stats
436+}
437+
310438 // WaitForReady blocks until every loaded extension has signalled
311439 // ReadyFromExt, or the grace period expires for the slowest one.
312440 //
@@ -551,7 +679,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
551679 ext.eventSubs[ev] = struct{}{}
552680 }
553681 for _, ev := range sub.Intercept {
554- if ev == "tool_call" { // only kind supported in v1
682+ switch ev {
683+ case "tool_call", "turn_start", "assistant_message":
555684 ext.interceptSubs[ev] = struct{}{}
556685 }
557686 }
@@ -132,6 +132,15 @@ type Manager struct {
132 // toolIndex maps an extension-defined tool name to its owning132 // toolIndex maps an extension-defined tool name to its owning
133 // extension. Same first-come-first-served rule as commandIndex.133 // extension. Same first-come-first-served rule as commandIndex.
134 toolIndex map[string]*Extension134 toolIndex map[string]*Extension
135+
136+ // explicitPaths remembers ad-hoc paths passed via --ext so
137+ // Reload can respawn them alongside the discovered set.
138+ explicitPaths []string
139+
140+ // onReload, if set, is invoked after a successful Reload. Used
141+ // by the host so it can rebuild the agent's tool registry with
142+ // the freshly-registered extension tools.
143+ onReload func()
135 }144 }
136 145
137 // New constructs an empty Manager. Call Discover to populate it from146 // New constructs an empty Manager. Call Discover to populate it from
@@ -283,12 +292,14 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
283 292
284 var wg sync.WaitGroup293 var wg sync.WaitGroup
285 errCh := make(chan error, len(paths))294 errCh := make(chan error, len(paths))
295+ absPaths := make([]string, 0, len(paths))
286 for _, p := range paths {296 for _, p := range paths {
287 abs, err := filepath.Abs(p)297 abs, err := filepath.Abs(p)
288 if err != nil {298 if err != nil {
289 errCh <- fmt.Errorf("%s: %w", p, err)299 errCh <- fmt.Errorf("%s: %w", p, err)
290 continue300 continue
291 }301 }
302+ absPaths = append(absPaths, abs)
292 wg.Add(1)303 wg.Add(1)
293 go func(extDir string) {304 go func(extDir string) {
294 defer wg.Done()305 defer wg.Done()
@@ -300,6 +311,10 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
300 wg.Wait()311 wg.Wait()
301 close(errCh)312 close(errCh)
302 313
314+ m.mu.Lock()
315+ m.explicitPaths = append(m.explicitPaths, absPaths...)
316+ m.mu.Unlock()
317+
303 var errs []error318 var errs []error
304 for e := range errCh {319 for e := range errCh {
305 errs = append(errs, e)320 errs = append(errs, e)
@@ -307,6 +322,119 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
307 return errs322 return errs
308 }323 }
309 324
325+// SetOnReload registers a callback fired after a successful Reload.
326+// Hosts use it to rebuild the agent's tool registry with freshly-
327+// registered extension tools.
328+func (m *Manager) SetOnReload(fn func()) {
329+ m.mu.Lock()
330+ m.onReload = fn
331+ m.mu.Unlock()
332+}
333+
334+// ReloadStats summarises the outcome of Reload.
335+type ReloadStats struct {
336+ Stopped int // how many old processes were torn down
337+ Loaded int // how many new processes reached spawn
338+ Ready int // how many of those signalled ready in time
339+ Errors []error // non-fatal per-extension errors
340+}
341+
342+// Reload tears down every running extension, re-reads the manifests
343+// from disk, respawns everyone (including the --ext paths remembered
344+// from LoadExplicit), waits up to grace for ready signals, and
345+// invokes the SetOnReload callback so the host can rebuild its tool
346+// registry. The manager's internal maps are cleared before the
347+// new load to ensure a clean slate.
348+//
349+// Safe to call concurrently with normal host operations: the lock is
350+// released between stop and respawn so pending InvokeTool / Invoke
351+// calls on the old processes get a clean error as their stdin
352+// closes.
353+func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
354+ stats := ReloadStats{}
355+
356+ // Snapshot and remember the explicit paths before we wipe state.
357+ m.mu.Lock()
358+ old := m.ext
359+ explicit := append([]string(nil), m.explicitPaths...)
360+ stats.Stopped = len(old)
361+ m.ext = map[string]*Extension{}
362+ m.commandIndex = map[string]*Extension{}
363+ m.toolIndex = map[string]*Extension{}
364+ m.explicitPaths = nil
365+ callback := m.onReload
366+ m.mu.Unlock()
367+
368+ // Graceful stop of the old set (reuses Stop's shutdown logic,
369+ // but Stop re-reads m.ext which is now empty, so we replicate
370+ // the small shutdown loop here on the snapshot).
371+ for _, ext := range old {
372+ if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
373+ _, _ = ext.stdin.Write(frame)
374+ }
375+ _ = ext.stdin.Close()
376+ }
377+ deadline := time.Now().Add(grace)
378+ for _, ext := range old {
379+ remaining := time.Until(deadline)
380+ if remaining <= 0 {
381+ remaining = 100 * time.Millisecond
382+ }
383+ done := make(chan struct{})
384+ go func(e *Extension) { _ = e.cmd.Wait(); close(done) }(ext)
385+ select {
386+ case <-done:
387+ case <-time.After(remaining):
388+ _ = ext.cmd.Process.Signal(syscall.SIGTERM)
389+ select {
390+ case <-done:
391+ case <-time.After(time.Second):
392+ _ = ext.cmd.Process.Kill()
393+ <-done
394+ }
395+ }
396+ if ext.logFile != nil {
397+ _ = ext.logFile.Close()
398+ }
399+ }
400+
401+ // Fresh load. Explicit paths first (they still win on conflict).
402+ if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
403+ stats.Errors = append(stats.Errors, errs...)
404+ }
405+ if errs := m.Discover(ctx); len(errs) > 0 {
406+ stats.Errors = append(stats.Errors, errs...)
407+ }
408+
409+ m.mu.RLock()
410+ stats.Loaded = len(m.ext)
411+ m.mu.RUnlock()
412+
413+ // Wait for ready frames. Use the same 3s grace zot uses at
414+ // startup so the reload feels no slower than a cold boot.
415+ readyDeadline := time.Now().Add(grace)
416+ if time.Until(readyDeadline) < 3*time.Second {
417+ readyDeadline = time.Now().Add(3 * time.Second)
418+ }
419+ m.WaitForReady(time.Until(readyDeadline))
420+
421+ m.mu.RLock()
422+ for _, ext := range m.ext {
423+ select {
424+ case <-ext.readyCh:
425+ stats.Ready++
426+ default:
427+ }
428+ }
429+ m.mu.RUnlock()
430+
431+ if callback != nil {
432+ callback()
433+ }
434+
435+ return stats
436+}
437+
310 // WaitForReady blocks until every loaded extension has signalled438 // WaitForReady blocks until every loaded extension has signalled
311 // ReadyFromExt, or the grace period expires for the slowest one.439 // ReadyFromExt, or the grace period expires for the slowest one.
312 //440 //
@@ -551,7 +679,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
551 ext.eventSubs[ev] = struct{}{}679 ext.eventSubs[ev] = struct{}{}
552 }680 }
553 for _, ev := range sub.Intercept {681 for _, ev := range sub.Intercept {
554- if ev == "tool_call" { // only kind supported in v1682+ switch ev {
683+ case "tool_call", "turn_start", "assistant_message":
555 ext.interceptSubs[ev] = struct{}{}684 ext.interceptSubs[ev] = struct{}{}
556 }685 }
557 }686 }
M internal/agent/modes/interactive.go +34 -0
@@ -1207,6 +1207,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
12071207 i.statusOK = "unlocked"
12081208 i.statusErr = ""
12091209 i.mu.Unlock()
1210+ case "/reload-ext":
1211+ i.runReloadExt(ctx)
12101212 default:
12111213 // Last-resort fallback: try the extension manager. Built-in
12121214 // cases above always win; this branch only fires for slash
@@ -1944,3 +1946,35 @@ func shortArgs(raw json.RawMessage) string {
19441946
19451947 // silence unused import in some build configs
19461948 var _ = fmt.Sprintf
1949+
1950+// runReloadExt triggers a live reload of every extension (discovered
1951+// + explicit). Runs on a goroutine so the TUI stays responsive; the
1952+// Manager.Reload takes a couple of hundred ms to shut down subprocs
1953+// and respawn them. Shows a status line throughout.
1954+func (i *Interactive) runReloadExt(ctx context.Context) {
1955+ if i.cfg.Extensions == nil {
1956+ i.mu.Lock()
1957+ i.statusErr = "no extension manager in this build"
1958+ i.mu.Unlock()
1959+ i.invalidate()
1960+ return
1961+ }
1962+ i.mu.Lock()
1963+ i.statusOK = "reloading extensions…"
1964+ i.statusErr = ""
1965+ i.mu.Unlock()
1966+ i.invalidate()
1967+
1968+ go func() {
1969+ stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
1970+ msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
1971+ if len(stats.Errors) > 0 {
1972+ msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
1973+ }
1974+ i.mu.Lock()
1975+ i.statusOK = msg
1976+ i.statusErr = ""
1977+ i.mu.Unlock()
1978+ i.invalidate()
1979+ }()
1980+}
@@ -1207,6 +1207,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1207 i.statusOK = "unlocked"1207 i.statusOK = "unlocked"
1208 i.statusErr = ""1208 i.statusErr = ""
1209 i.mu.Unlock()1209 i.mu.Unlock()
1210+ case "/reload-ext":
1211+ i.runReloadExt(ctx)
1210 default:1212 default:
1211 // Last-resort fallback: try the extension manager. Built-in1213 // Last-resort fallback: try the extension manager. Built-in
1212 // cases above always win; this branch only fires for slash1214 // cases above always win; this branch only fires for slash
@@ -1944,3 +1946,35 @@ func shortArgs(raw json.RawMessage) string {
1944 1946
1945 // silence unused import in some build configs1947 // silence unused import in some build configs
1946 var _ = fmt.Sprintf1948 var _ = fmt.Sprintf
1949+
1950+// runReloadExt triggers a live reload of every extension (discovered
1951+// + explicit). Runs on a goroutine so the TUI stays responsive; the
1952+// Manager.Reload takes a couple of hundred ms to shut down subprocs
1953+// and respawn them. Shows a status line throughout.
1954+func (i *Interactive) runReloadExt(ctx context.Context) {
1955+ if i.cfg.Extensions == nil {
1956+ i.mu.Lock()
1957+ i.statusErr = "no extension manager in this build"
1958+ i.mu.Unlock()
1959+ i.invalidate()
1960+ return
1961+ }
1962+ i.mu.Lock()
1963+ i.statusOK = "reloading extensions…"
1964+ i.statusErr = ""
1965+ i.mu.Unlock()
1966+ i.invalidate()
1967+
1968+ go func() {
1969+ stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
1970+ msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
1971+ if len(stats.Errors) > 0 {
1972+ msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
1973+ }
1974+ i.mu.Lock()
1975+ i.statusOK = msg
1976+ i.statusErr = ""
1977+ i.mu.Unlock()
1978+ i.invalidate()
1979+ }()
1980+}
M internal/agent/modes/slash_suggest.go +2 -1
@@ -23,7 +23,7 @@ type slashCommand struct {
2323 // the streaming response without trouble.
2424 func slashCancelsTurn(head string) bool {
2525 switch head {
26- case "/clear", "/compact", "/logout", "/login", "/model":
26+ case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext":
2727 return true
2828 }
2929 return false
@@ -43,6 +43,7 @@ var slashCatalog = []slashCommand{
4343 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
4444 {Name: "/lock", Desc: "confine tools to the current directory"},
4545 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
46+ {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
4647 {Name: "/clear", Desc: "clear the chat transcript"},
4748 {Name: "/exit", Desc: "exit zot"},
4849 }
@@ -23,7 +23,7 @@ type slashCommand struct {
23 // the streaming response without trouble.23 // the streaming response without trouble.
24 func slashCancelsTurn(head string) bool {24 func slashCancelsTurn(head string) bool {
25 switch head {25 switch head {
26- case "/clear", "/compact", "/logout", "/login", "/model":26+ case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext":
27 return true27 return true
28 }28 }
29 return false29 return false
@@ -43,6 +43,7 @@ var slashCatalog = []slashCommand{
43 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},43 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
44 {Name: "/lock", Desc: "confine tools to the current directory"},44 {Name: "/lock", Desc: "confine tools to the current directory"},
45 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},45 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
46+ {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
46 {Name: "/clear", Desc: "clear the chat transcript"},47 {Name: "/clear", Desc: "clear the chat transcript"},
47 {Name: "/exit", Desc: "exit zot"},48 {Name: "/exit", Desc: "exit zot"},
48 }49 }
M internal/agent/rpc.go +32 -2
@@ -63,10 +63,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
6363 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
6464
6565 ag := r.NewAgent()
66- ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {
67- return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
66+ ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
67+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
68+ if r.Block {
69+ return false, r.Reason, nil
70+ }
71+ return true, "", r.ModifiedArgs
72+ }
73+ ag.BeforeTurn = func(step int) (bool, string) {
74+ r := extMgr.InterceptTurnStart(ctx, step)
75+ return !r.Block, r.Reason
76+ }
77+ ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
78+ r := extMgr.InterceptAssistantMessage(ctx, text)
79+ if r.Block {
80+ return false, r.Reason, ""
81+ }
82+ return true, "", r.ReplaceText
6883 }
6984 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
85+
86+ // /reload-ext hot-reload callback (also triggered via rpc
87+ // `reload_ext` if/when added). Rebuilds the tool registry on the
88+ // current agent so freshly-registered extension tools become
89+ // callable without restarting the rpc process.
90+ adapter := &extToolAdapter{mgr: extMgr}
91+ extMgr.SetOnReload(func() {
92+ resolved, err := Resolve(args, true)
93+ if err != nil {
94+ return
95+ }
96+ resolved.MergeExtensionTools(adapter)
97+ ag.SetTools(resolved.ToolRegistry)
98+ })
99+
70100 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
71101
72102 server := &rpcServer{
@@ -63,10 +63,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
63 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})63 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
64 64
65 ag := r.NewAgent()65 ag := r.NewAgent()
66- ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {66+ ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
67- return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)67+ r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
68+ if r.Block {
69+ return false, r.Reason, nil
70+ }
71+ return true, "", r.ModifiedArgs
72+ }
73+ ag.BeforeTurn = func(step int) (bool, string) {
74+ r := extMgr.InterceptTurnStart(ctx, step)
75+ return !r.Block, r.Reason
76+ }
77+ ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
78+ r := extMgr.InterceptAssistantMessage(ctx, text)
79+ if r.Block {
80+ return false, r.Reason, ""
81+ }
82+ return true, "", r.ReplaceText
68 }83 }
69 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }84 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
85+
86+ // /reload-ext hot-reload callback (also triggered via rpc
87+ // `reload_ext` if/when added). Rebuilds the tool registry on the
88+ // current agent so freshly-registered extension tools become
89+ // callable without restarting the rpc process.
90+ adapter := &extToolAdapter{mgr: extMgr}
91+ extMgr.SetOnReload(func() {
92+ resolved, err := Resolve(args, true)
93+ if err != nil {
94+ return
95+ }
96+ resolved.MergeExtensionTools(adapter)
97+ ag.SetTools(resolved.ToolRegistry)
98+ })
99+
70 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})100 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
71 101
72 server := &rpcServer{102 server := &rpcServer{
M internal/core/agent.go +114 -11
@@ -23,10 +23,27 @@ type Agent struct {
2323
2424 // BeforeToolExecute, if set, is called immediately before each
2525 // tool runs. Returning (allowed=false, reason) short-circuits
26- // the call with an error result containing reason. Used by the
27- // extension manager's tool-call interception. Always treated as
28- // (allowed=true, "") when nil.
29- BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string)
26+ // the call with an error result containing reason. Optionally,
27+ // returning a non-nil modifiedArgs replaces the JSON args the
28+ // tool will see, which lets guards redact / augment / patch the
29+ // model's request without rewriting the transcript. Empty or
30+ // malformed modifiedArgs is ignored.
31+ BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
32+
33+ // BeforeTurn, if set, is called before each turn's model call.
34+ // Returning (allowed=false, reason) aborts the turn; reason is
35+ // surfaced as an assistant-like status line. Used for rate-
36+ // limiting, business-hour gates, and deny-by-default setups.
37+ BeforeTurn func(step int) (allowed bool, reason string)
38+
39+ // BeforeAssistantMessage, if set, is called after the model's
40+ // final assistant message is assembled but before it's appended
41+ // to the transcript. Returning (allowed=false) suppresses both
42+ // the transcript append and the UI event. A non-empty
43+ // replacement rewrites the visible text for the user while
44+ // leaving the model's original text in the transcript (so the
45+ // model can still see what it said in subsequent turns).
46+ BeforeAssistantMessage func(text string) (allowed bool, reason, replacement string)
3047
3148 // OnEvent, if set, mirrors every AgentEvent the loop emits to
3249 // this callback in addition to the per-Prompt sink. Used by the
@@ -59,6 +76,15 @@ func (a *Agent) Messages() []provider.Message {
5976 return out
6077 }
6178
79+// SetTools swaps the tool registry. Used by /reload-ext to hand
80+// the agent a fresh registry after extension subprocesses have been
81+// respawned (and their freshly-registered tools merged in).
82+func (a *Agent) SetTools(reg Registry) {
83+ a.mu.Lock()
84+ a.Tools = reg
85+ a.mu.Unlock()
86+}
87+
6288 // SetMessages replaces the transcript (used when resuming a session).
6389 func (a *Agent) SetMessages(msgs []provider.Message) {
6490 a.mu.Lock()
@@ -134,6 +160,16 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
134160 func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
135161 for step := 1; step <= a.MaxSteps; step++ {
136162 sink(EvTurnStart{Step: step})
163+ if a.BeforeTurn != nil {
164+ if allowed, reason := a.BeforeTurn(step); !allowed {
165+ if reason == "" {
166+ reason = "turn blocked by extension guard"
167+ }
168+ sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
169+ sink(EvDone{})
170+ return nil
171+ }
172+ }
137173 stop, assistantMsg, err := a.oneTurn(ctx, sink)
138174 sink(EvTurnEnd{Stop: stop, Err: err})
139175 if err != nil {
@@ -208,12 +244,33 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
208244
209245 // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
210246 // produce partial, mid-sentence content that would confuse subsequent
211- // turns if it stayed in the transcript — drop it instead.
247+ // turns if it stayed in the transcript, drop it instead.
212248 if len(finalMsg.Content) > 0 && stop != provider.StopAborted {
249+ emit := finalMsg
250+ suppress := false
251+
252+ // BeforeAssistantMessage hook: extensions can suppress or
253+ // rewrite the visible text. The transcript keeps the
254+ // model's original output so the model still sees what it
255+ // said on subsequent turns.
256+ if a.BeforeAssistantMessage != nil {
257+ orig := extractText(finalMsg)
258+ if orig != "" {
259+ allowed, _, replacement := a.BeforeAssistantMessage(orig)
260+ if !allowed {
261+ suppress = true
262+ } else if replacement != "" && replacement != orig {
263+ emit = replaceText(finalMsg, replacement)
264+ }
265+ }
266+ }
267+
213268 a.mu.Lock()
214269 a.messages = append(a.messages, finalMsg)
215270 a.mu.Unlock()
216- sink(EvAssistantMessage{Message: finalMsg})
271+ if !suppress {
272+ sink(EvAssistantMessage{Message: emit})
273+ }
217274 // Now surface tool calls as EvToolCall events so UIs can render them
218275 // in order before the tool results arrive.
219276 for _, c := range finalMsg.Content {
@@ -265,12 +322,17 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
265322 }
266323 }
267324
325+ args := tc.Arguments
326+
268327 // Intercept hook: an extension or other guard can refuse the
269- // call before any side effect happens. The model sees the
270- // reason as the tool error, learns from it, and (typically)
271- // proposes a different action.
328+ // call before any side effect happens, OR rewrite the args
329+ // seen by the tool. The model sees the reason as the tool
330+ // error, learns from it, and (typically) proposes a different
331+ // action; rewrites are invisible to the model (they apply only
332+ // to the execution).
272333 if a.BeforeToolExecute != nil {
273- if allowed, reason := a.BeforeToolExecute(tc); !allowed {
334+ allowed, reason, modified := a.BeforeToolExecute(tc)
335+ if !allowed {
274336 if reason == "" {
275337 reason = "tool call refused by extension guard"
276338 }
@@ -279,9 +341,11 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
279341 IsError: true,
280342 }
281343 }
344+ if len(modified) > 0 && json.Valid(modified) {
345+ args = modified
346+ }
282347 }
283348
284- args := tc.Arguments
285349 if len(args) == 0 {
286350 args = json.RawMessage("{}")
287351 }
@@ -318,3 +382,42 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
318382 }()
319383 return res
320384 }
385+
386+// extractText concatenates all TextBlock content in a message. Used
387+// by BeforeAssistantMessage so guards see a single string instead of
388+// having to walk provider.Content themselves.
389+func extractText(msg provider.Message) string {
390+ var out string
391+ for _, c := range msg.Content {
392+ if tb, ok := c.(provider.TextBlock); ok {
393+ if out != "" {
394+ out += "\n"
395+ }
396+ out += tb.Text
397+ }
398+ }
399+ return out
400+}
401+
402+// replaceText returns a copy of msg with every TextBlock replaced by
403+// a single TextBlock containing replacement. Non-text content (tool
404+// calls, etc.) is preserved in order.
405+func replaceText(msg provider.Message, replacement string) provider.Message {
406+ out := provider.Message{Role: msg.Role}
407+ out.Content = make([]provider.Content, 0, len(msg.Content))
408+ replaced := false
409+ for _, c := range msg.Content {
410+ if _, ok := c.(provider.TextBlock); ok {
411+ if !replaced {
412+ out.Content = append(out.Content, provider.TextBlock{Text: replacement})
413+ replaced = true
414+ }
415+ continue
416+ }
417+ out.Content = append(out.Content, c)
418+ }
419+ if !replaced {
420+ out.Content = append(out.Content, provider.TextBlock{Text: replacement})
421+ }
422+ return out
423+}
@@ -23,10 +23,27 @@ type Agent struct {
23 23
24 // BeforeToolExecute, if set, is called immediately before each24 // BeforeToolExecute, if set, is called immediately before each
25 // tool runs. Returning (allowed=false, reason) short-circuits25 // tool runs. Returning (allowed=false, reason) short-circuits
26- // the call with an error result containing reason. Used by the26+ // the call with an error result containing reason. Optionally,
27- // extension manager's tool-call interception. Always treated as27+ // returning a non-nil modifiedArgs replaces the JSON args the
28- // (allowed=true, "") when nil.28+ // tool will see, which lets guards redact / augment / patch the
29- BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string)29+ // model's request without rewriting the transcript. Empty or
30+ // malformed modifiedArgs is ignored.
31+ BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
32+
33+ // BeforeTurn, if set, is called before each turn's model call.
34+ // Returning (allowed=false, reason) aborts the turn; reason is
35+ // surfaced as an assistant-like status line. Used for rate-
36+ // limiting, business-hour gates, and deny-by-default setups.
37+ BeforeTurn func(step int) (allowed bool, reason string)
38+
39+ // BeforeAssistantMessage, if set, is called after the model's
40+ // final assistant message is assembled but before it's appended
41+ // to the transcript. Returning (allowed=false) suppresses both
42+ // the transcript append and the UI event. A non-empty
43+ // replacement rewrites the visible text for the user while
44+ // leaving the model's original text in the transcript (so the
45+ // model can still see what it said in subsequent turns).
46+ BeforeAssistantMessage func(text string) (allowed bool, reason, replacement string)
30 47
31 // OnEvent, if set, mirrors every AgentEvent the loop emits to48 // OnEvent, if set, mirrors every AgentEvent the loop emits to
32 // this callback in addition to the per-Prompt sink. Used by the49 // this callback in addition to the per-Prompt sink. Used by the
@@ -59,6 +76,15 @@ func (a *Agent) Messages() []provider.Message {
59 return out76 return out
60 }77 }
61 78
79+// SetTools swaps the tool registry. Used by /reload-ext to hand
80+// the agent a fresh registry after extension subprocesses have been
81+// respawned (and their freshly-registered tools merged in).
82+func (a *Agent) SetTools(reg Registry) {
83+ a.mu.Lock()
84+ a.Tools = reg
85+ a.mu.Unlock()
86+}
87+
62 // SetMessages replaces the transcript (used when resuming a session).88 // SetMessages replaces the transcript (used when resuming a session).
63 func (a *Agent) SetMessages(msgs []provider.Message) {89 func (a *Agent) SetMessages(msgs []provider.Message) {
64 a.mu.Lock()90 a.mu.Lock()
@@ -134,6 +160,16 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
134 func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {160 func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
135 for step := 1; step <= a.MaxSteps; step++ {161 for step := 1; step <= a.MaxSteps; step++ {
136 sink(EvTurnStart{Step: step})162 sink(EvTurnStart{Step: step})
163+ if a.BeforeTurn != nil {
164+ if allowed, reason := a.BeforeTurn(step); !allowed {
165+ if reason == "" {
166+ reason = "turn blocked by extension guard"
167+ }
168+ sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
169+ sink(EvDone{})
170+ return nil
171+ }
172+ }
137 stop, assistantMsg, err := a.oneTurn(ctx, sink)173 stop, assistantMsg, err := a.oneTurn(ctx, sink)
138 sink(EvTurnEnd{Stop: stop, Err: err})174 sink(EvTurnEnd{Stop: stop, Err: err})
139 if err != nil {175 if err != nil {
@@ -208,12 +244,33 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
208 244
209 // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)245 // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
210 // produce partial, mid-sentence content that would confuse subsequent246 // produce partial, mid-sentence content that would confuse subsequent
211- // turns if it stayed in the transcript — drop it instead.247+ // turns if it stayed in the transcript, drop it instead.
212 if len(finalMsg.Content) > 0 && stop != provider.StopAborted {248 if len(finalMsg.Content) > 0 && stop != provider.StopAborted {
249+ emit := finalMsg
250+ suppress := false
251+
252+ // BeforeAssistantMessage hook: extensions can suppress or
253+ // rewrite the visible text. The transcript keeps the
254+ // model's original output so the model still sees what it
255+ // said on subsequent turns.
256+ if a.BeforeAssistantMessage != nil {
257+ orig := extractText(finalMsg)
258+ if orig != "" {
259+ allowed, _, replacement := a.BeforeAssistantMessage(orig)
260+ if !allowed {
261+ suppress = true
262+ } else if replacement != "" && replacement != orig {
263+ emit = replaceText(finalMsg, replacement)
264+ }
265+ }
266+ }
267+
213 a.mu.Lock()268 a.mu.Lock()
214 a.messages = append(a.messages, finalMsg)269 a.messages = append(a.messages, finalMsg)
215 a.mu.Unlock()270 a.mu.Unlock()
216- sink(EvAssistantMessage{Message: finalMsg})271+ if !suppress {
272+ sink(EvAssistantMessage{Message: emit})
273+ }
217 // Now surface tool calls as EvToolCall events so UIs can render them274 // Now surface tool calls as EvToolCall events so UIs can render them
218 // in order before the tool results arrive.275 // in order before the tool results arrive.
219 for _, c := range finalMsg.Content {276 for _, c := range finalMsg.Content {
@@ -265,12 +322,17 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
265 }322 }
266 }323 }
267 324
325+ args := tc.Arguments
326+
268 // Intercept hook: an extension or other guard can refuse the327 // Intercept hook: an extension or other guard can refuse the
269- // call before any side effect happens. The model sees the328+ // call before any side effect happens, OR rewrite the args
270- // reason as the tool error, learns from it, and (typically)329+ // seen by the tool. The model sees the reason as the tool
271- // proposes a different action.330+ // error, learns from it, and (typically) proposes a different
331+ // action; rewrites are invisible to the model (they apply only
332+ // to the execution).
272 if a.BeforeToolExecute != nil {333 if a.BeforeToolExecute != nil {
273- if allowed, reason := a.BeforeToolExecute(tc); !allowed {334+ allowed, reason, modified := a.BeforeToolExecute(tc)
335+ if !allowed {
274 if reason == "" {336 if reason == "" {
275 reason = "tool call refused by extension guard"337 reason = "tool call refused by extension guard"
276 }338 }
@@ -279,9 +341,11 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
279 IsError: true,341 IsError: true,
280 }342 }
281 }343 }
344+ if len(modified) > 0 && json.Valid(modified) {
345+ args = modified
346+ }
282 }347 }
283 348
284- args := tc.Arguments
285 if len(args) == 0 {349 if len(args) == 0 {
286 args = json.RawMessage("{}")350 args = json.RawMessage("{}")
287 }351 }
@@ -318,3 +382,42 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
318 }()382 }()
319 return res383 return res
320 }384 }
385+
386+// extractText concatenates all TextBlock content in a message. Used
387+// by BeforeAssistantMessage so guards see a single string instead of
388+// having to walk provider.Content themselves.
389+func extractText(msg provider.Message) string {
390+ var out string
391+ for _, c := range msg.Content {
392+ if tb, ok := c.(provider.TextBlock); ok {
393+ if out != "" {
394+ out += "\n"
395+ }
396+ out += tb.Text
397+ }
398+ }
399+ return out
400+}
401+
402+// replaceText returns a copy of msg with every TextBlock replaced by
403+// a single TextBlock containing replacement. Non-text content (tool
404+// calls, etc.) is preserved in order.
405+func replaceText(msg provider.Message, replacement string) provider.Message {
406+ out := provider.Message{Role: msg.Role}
407+ out.Content = make([]provider.Content, 0, len(msg.Content))
408+ replaced := false
409+ for _, c := range msg.Content {
410+ if _, ok := c.(provider.TextBlock); ok {
411+ if !replaced {
412+ out.Content = append(out.Content, provider.TextBlock{Text: replacement})
413+ replaced = true
414+ }
415+ continue
416+ }
417+ out.Content = append(out.Content, c)
418+ }
419+ if !replaced {
420+ out.Content = append(out.Content, provider.TextBlock{Text: replacement})
421+ }
422+ return out
423+}
A internal/core/intercept_test.go +109 -0
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/internal/provider"
9+)
10+
11+// recordingTool captures the args it was invoked with so the test
12+// can verify the interceptor-rewritten args reached execution.
13+type recordingTool struct {
14+ lastArgs json.RawMessage
15+}
16+
17+func (r *recordingTool) Name() string { return "echo" }
18+func (r *recordingTool) Description() string { return "echoes" }
19+func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
20+
21+func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
22+ r.lastArgs = append(json.RawMessage(nil), args...)
23+ return ToolResult{
24+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
25+ }, nil
26+}
27+
28+// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
29+// modifiedArgs returned from BeforeToolExecute is what the tool
30+// actually sees.
31+func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
32+ rec := &recordingTool{}
33+ reg := Registry{"echo": rec}
34+ a := NewAgent(nil, "test", "", reg)
35+
36+ newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
37+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
38+ return true, "", newArgs
39+ }
40+
41+ ctx := context.Background()
42+ res := a.runOneTool(ctx, provider.ToolCallBlock{
43+ ID: "T1",
44+ Name: "echo",
45+ Arguments: json.RawMessage(`{"command":"ls"}`),
46+ }, func(AgentEvent) {})
47+ if res.IsError {
48+ t.Fatalf("unexpected error result: %v", res.Content)
49+ }
50+ if string(rec.lastArgs) != string(newArgs) {
51+ t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
52+ }
53+}
54+
55+// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
56+// malformed JSON as modifiedArgs leaves the original args intact
57+// (safety: a buggy interceptor can't corrupt the call).
58+func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
59+ rec := &recordingTool{}
60+ reg := Registry{"echo": rec}
61+ a := NewAgent(nil, "test", "", reg)
62+
63+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
64+ return true, "", json.RawMessage(`{not json`)
65+ }
66+
67+ ctx := context.Background()
68+ orig := json.RawMessage(`{"command":"ls"}`)
69+ a.runOneTool(ctx, provider.ToolCallBlock{
70+ ID: "T1",
71+ Name: "echo",
72+ Arguments: orig,
73+ }, func(AgentEvent) {})
74+ if string(rec.lastArgs) != string(orig) {
75+ t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
76+ }
77+}
78+
79+// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
80+// the interceptor returns an error ToolResult with the reason text.
81+func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
82+ rec := &recordingTool{}
83+ reg := Registry{"echo": rec}
84+ a := NewAgent(nil, "test", "", reg)
85+
86+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
87+ return false, "nope", nil
88+ }
89+
90+ ctx := context.Background()
91+ res := a.runOneTool(ctx, provider.ToolCallBlock{
92+ ID: "T1",
93+ Name: "echo",
94+ Arguments: json.RawMessage(`{"command":"ls"}`),
95+ }, func(AgentEvent) {})
96+ if !res.IsError {
97+ t.Fatal("want error result, got success")
98+ }
99+ if len(res.Content) == 0 {
100+ t.Fatal("no content")
101+ }
102+ tb, ok := res.Content[0].(provider.TextBlock)
103+ if !ok || tb.Text != "nope" {
104+ t.Errorf("want reason 'nope', got %v", res.Content[0])
105+ }
106+ if rec.lastArgs != nil {
107+ t.Error("tool ran despite block")
108+ }
109+}
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/internal/provider"
9+)
10+
11+// recordingTool captures the args it was invoked with so the test
12+// can verify the interceptor-rewritten args reached execution.
13+type recordingTool struct {
14+ lastArgs json.RawMessage
15+}
16+
17+func (r *recordingTool) Name() string { return "echo" }
18+func (r *recordingTool) Description() string { return "echoes" }
19+func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
20+
21+func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
22+ r.lastArgs = append(json.RawMessage(nil), args...)
23+ return ToolResult{
24+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
25+ }, nil
26+}
27+
28+// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
29+// modifiedArgs returned from BeforeToolExecute is what the tool
30+// actually sees.
31+func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
32+ rec := &recordingTool{}
33+ reg := Registry{"echo": rec}
34+ a := NewAgent(nil, "test", "", reg)
35+
36+ newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
37+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
38+ return true, "", newArgs
39+ }
40+
41+ ctx := context.Background()
42+ res := a.runOneTool(ctx, provider.ToolCallBlock{
43+ ID: "T1",
44+ Name: "echo",
45+ Arguments: json.RawMessage(`{"command":"ls"}`),
46+ }, func(AgentEvent) {})
47+ if res.IsError {
48+ t.Fatalf("unexpected error result: %v", res.Content)
49+ }
50+ if string(rec.lastArgs) != string(newArgs) {
51+ t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
52+ }
53+}
54+
55+// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
56+// malformed JSON as modifiedArgs leaves the original args intact
57+// (safety: a buggy interceptor can't corrupt the call).
58+func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
59+ rec := &recordingTool{}
60+ reg := Registry{"echo": rec}
61+ a := NewAgent(nil, "test", "", reg)
62+
63+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
64+ return true, "", json.RawMessage(`{not json`)
65+ }
66+
67+ ctx := context.Background()
68+ orig := json.RawMessage(`{"command":"ls"}`)
69+ a.runOneTool(ctx, provider.ToolCallBlock{
70+ ID: "T1",
71+ Name: "echo",
72+ Arguments: orig,
73+ }, func(AgentEvent) {})
74+ if string(rec.lastArgs) != string(orig) {
75+ t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
76+ }
77+}
78+
79+// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
80+// the interceptor returns an error ToolResult with the reason text.
81+func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
82+ rec := &recordingTool{}
83+ reg := Registry{"echo": rec}
84+ a := NewAgent(nil, "test", "", reg)
85+
86+ a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
87+ return false, "nope", nil
88+ }
89+
90+ ctx := context.Background()
91+ res := a.runOneTool(ctx, provider.ToolCallBlock{
92+ ID: "T1",
93+ Name: "echo",
94+ Arguments: json.RawMessage(`{"command":"ls"}`),
95+ }, func(AgentEvent) {})
96+ if !res.IsError {
97+ t.Fatal("want error result, got success")
98+ }
99+ if len(res.Content) == 0 {
100+ t.Fatal("no content")
101+ }
102+ tb, ok := res.Content[0].(provider.TextBlock)
103+ if !ok || tb.Text != "nope" {
104+ t.Errorf("want reason 'nope', got %v", res.Content[0])
105+ }
106+ if rec.lastArgs != nil {
107+ t.Error("tool ran despite block")
108+ }
109+}
M internal/extproto/extproto.go +39 -11
@@ -91,13 +91,27 @@ type SubscribeFromExt struct {
9191
9292 // EventInterceptResponseFromExt is the extension's reply to an
9393 // EventInterceptFromHost. block=true refuses the underlying action;
94-// reason is shown to the model as the tool error message. Both
95-// fields default to (false, "") meaning "allow".
94+// reason is shown to the model (or the user) as the refusal text.
95+// All fields default to "allow, pass through unmodified".
96+//
97+// Optional rewrite fields, their meaning depends on the event:
98+//
99+// - ModifiedArgs: for event="tool_call", replaces the args the
100+// tool will see. Must be a JSON object literal or the rewrite is
101+// dropped and a warning logged.
102+// - ReplaceText: for event="assistant_message", replaces the user-
103+// visible text. The model's original text stays in the transcript
104+// (so the model can reference what it "said"); only the rendered
105+// output to the user is swapped.
106+//
107+// When block=true, rewrite fields are ignored.
96108 type EventInterceptResponseFromExt struct {
97- Type string `json:"type"` // "event_intercept_response"
98- ID string `json:"id"`
99- Block bool `json:"block,omitempty"`
100- Reason string `json:"reason,omitempty"`
109+ Type string `json:"type"` // "event_intercept_response"
110+ ID string `json:"id"`
111+ Block bool `json:"block,omitempty"`
112+ Reason string `json:"reason,omitempty"`
113+ ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
114+ ReplaceText string `json:"replace_text,omitempty"`
101115 }
102116
103117 // ToolResultFromExt is the extension's reply to a ToolCallFromHost.
@@ -215,20 +229,34 @@ type EventFromHost struct {
215229 }
216230
217231 // EventInterceptFromHost is sent when zot wants to give the
218-// extension a chance to block / annotate a lifecycle event before
219-// it happens. Same payload shape as EventFromHost. Reply with
220-// EventInterceptResponseFromExt within the host's intercept timeout
221-// (default 5s); missing the deadline is treated as "allow".
232+// extension a chance to block, modify, or annotate a lifecycle
233+// event before it happens. Reply with EventInterceptResponseFromExt
234+// within the host's intercept timeout (default 5s); missing the
235+// deadline is treated as "allow".
236+//
237+// Supported events and their effect on block=true:
222238 //
223-// Only Event="tool_call" is sent in this version.
239+// - tool_call: cancel the tool; model sees reason as error.
240+// Can also modify args via ModifiedArgs.
241+// - turn_start: cancel the turn before the model call.
242+// Reason is shown as a chat status line.
243+// - assistant_message: suppress the message. Can also rewrite
244+// the user-visible text via ReplaceText.
224245 type EventInterceptFromHost struct {
225246 Type string `json:"type"` // "event_intercept"
226247 ID string `json:"id"`
227248 Event string `json:"event"`
228249
250+ // tool_call payload
229251 ToolID string `json:"tool_id,omitempty"`
230252 ToolName string `json:"tool_name,omitempty"`
231253 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
254+
255+ // turn_start payload
256+ Step int `json:"step,omitempty"`
257+
258+ // assistant_message payload
259+ Text string `json:"text,omitempty"`
232260 }
233261
234262 // ShutdownFromHost asks the extension to clean up and exit. Zot
@@ -91,13 +91,27 @@ type SubscribeFromExt struct {
91 91
92 // EventInterceptResponseFromExt is the extension's reply to an92 // EventInterceptResponseFromExt is the extension's reply to an
93 // EventInterceptFromHost. block=true refuses the underlying action;93 // EventInterceptFromHost. block=true refuses the underlying action;
94-// reason is shown to the model as the tool error message. Both94+// reason is shown to the model (or the user) as the refusal text.
95-// fields default to (false, "") meaning "allow".95+// All fields default to "allow, pass through unmodified".
96+//
97+// Optional rewrite fields, their meaning depends on the event:
98+//
99+// - ModifiedArgs: for event="tool_call", replaces the args the
100+// tool will see. Must be a JSON object literal or the rewrite is
101+// dropped and a warning logged.
102+// - ReplaceText: for event="assistant_message", replaces the user-
103+// visible text. The model's original text stays in the transcript
104+// (so the model can reference what it "said"); only the rendered
105+// output to the user is swapped.
106+//
107+// When block=true, rewrite fields are ignored.
96 type EventInterceptResponseFromExt struct {108 type EventInterceptResponseFromExt struct {
97- Type string `json:"type"` // "event_intercept_response"109+ Type string `json:"type"` // "event_intercept_response"
98- ID string `json:"id"`110+ ID string `json:"id"`
99- Block bool `json:"block,omitempty"`111+ Block bool `json:"block,omitempty"`
100- Reason string `json:"reason,omitempty"`112+ Reason string `json:"reason,omitempty"`
113+ ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
114+ ReplaceText string `json:"replace_text,omitempty"`
101 }115 }
102 116
103 // ToolResultFromExt is the extension's reply to a ToolCallFromHost.117 // ToolResultFromExt is the extension's reply to a ToolCallFromHost.
@@ -215,20 +229,34 @@ type EventFromHost struct {
215 }229 }
216 230
217 // EventInterceptFromHost is sent when zot wants to give the231 // EventInterceptFromHost is sent when zot wants to give the
218-// extension a chance to block / annotate a lifecycle event before232+// extension a chance to block, modify, or annotate a lifecycle
219-// it happens. Same payload shape as EventFromHost. Reply with233+// event before it happens. Reply with EventInterceptResponseFromExt
220-// EventInterceptResponseFromExt within the host's intercept timeout234+// within the host's intercept timeout (default 5s); missing the
221-// (default 5s); missing the deadline is treated as "allow".235+// deadline is treated as "allow".
236+//
237+// Supported events and their effect on block=true:
222 //238 //
223-// Only Event="tool_call" is sent in this version.239+// - tool_call: cancel the tool; model sees reason as error.
240+// Can also modify args via ModifiedArgs.
241+// - turn_start: cancel the turn before the model call.
242+// Reason is shown as a chat status line.
243+// - assistant_message: suppress the message. Can also rewrite
244+// the user-visible text via ReplaceText.
224 type EventInterceptFromHost struct {245 type EventInterceptFromHost struct {
225 Type string `json:"type"` // "event_intercept"246 Type string `json:"type"` // "event_intercept"
226 ID string `json:"id"`247 ID string `json:"id"`
227 Event string `json:"event"`248 Event string `json:"event"`
228 249
250+ // tool_call payload
229 ToolID string `json:"tool_id,omitempty"`251 ToolID string `json:"tool_id,omitempty"`
230 ToolName string `json:"tool_name,omitempty"`252 ToolName string `json:"tool_name,omitempty"`
231 ToolArgs json.RawMessage `json:"tool_args,omitempty"`253 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
254+
255+ // turn_start payload
256+ Step int `json:"step,omitempty"`
257+
258+ // assistant_message payload
259+ Text string `json:"text,omitempty"`
232 }260 }
233 261
234 // ShutdownFromHost asks the extension to clean up and exit. Zot262 // ShutdownFromHost asks the extension to clean up and exit. Zot
A internal/tui/statusbar_test.go +58 -0
@@ -0,0 +1,58 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+)
9+
10+// TestStatusBarAlwaysTwoLines verifies the status bar always emits
11+// two lines when a cwd is present, regardless of terminal width, and
12+// that the cwd is indented with the 2-space pad so it lines up under
13+// the "(provider)" column on line 1.
14+func TestStatusBarAlwaysTwoLines(t *testing.T) {
15+ // Wide terminal that would previously combine into one line.
16+ lines := StatusBar(StatusBarParams{
17+ Theme: Dark,
18+ Provider: "anthropic",
19+ Model: "claude-opus-4-7",
20+ CWD: "/tmp/x",
21+ Usage: provider.Usage{
22+ InputTokens: 476_000,
23+ OutputTokens: 3_400,
24+ CostUSD: 1.242,
25+ },
26+ Subscription: true,
27+ ContextUsed: 55_000,
28+ ContextMax: 1_000_000,
29+ Cols: 500, // very wide
30+ })
31+ if len(lines) != 2 {
32+ t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
33+ }
34+ if !strings.Contains(lines[0], "claude-opus-4-7") {
35+ t.Errorf("line 1 should contain model, got %q", lines[0])
36+ }
37+ // Line 2 must start with 2-space indent.
38+ if !strings.HasPrefix(lines[1], " ") {
39+ t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
40+ }
41+ if !strings.Contains(lines[1], "/tmp/x") {
42+ t.Errorf("line 2 should contain cwd, got %q", lines[1])
43+ }
44+}
45+
46+// TestStatusBarNoCWD verifies an empty cwd stays single-line.
47+func TestStatusBarNoCWD(t *testing.T) {
48+ lines := StatusBar(StatusBarParams{
49+ Theme: Dark,
50+ Provider: "openai",
51+ Model: "gpt-5.4",
52+ CWD: "",
53+ Cols: 200,
54+ })
55+ if len(lines) != 1 {
56+ t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)
57+ }
58+}
@@ -0,0 +1,58 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+)
9+
10+// TestStatusBarAlwaysTwoLines verifies the status bar always emits
11+// two lines when a cwd is present, regardless of terminal width, and
12+// that the cwd is indented with the 2-space pad so it lines up under
13+// the "(provider)" column on line 1.
14+func TestStatusBarAlwaysTwoLines(t *testing.T) {
15+ // Wide terminal that would previously combine into one line.
16+ lines := StatusBar(StatusBarParams{
17+ Theme: Dark,
18+ Provider: "anthropic",
19+ Model: "claude-opus-4-7",
20+ CWD: "/tmp/x",
21+ Usage: provider.Usage{
22+ InputTokens: 476_000,
23+ OutputTokens: 3_400,
24+ CostUSD: 1.242,
25+ },
26+ Subscription: true,
27+ ContextUsed: 55_000,
28+ ContextMax: 1_000_000,
29+ Cols: 500, // very wide
30+ })
31+ if len(lines) != 2 {
32+ t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
33+ }
34+ if !strings.Contains(lines[0], "claude-opus-4-7") {
35+ t.Errorf("line 1 should contain model, got %q", lines[0])
36+ }
37+ // Line 2 must start with 2-space indent.
38+ if !strings.HasPrefix(lines[1], " ") {
39+ t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
40+ }
41+ if !strings.Contains(lines[1], "/tmp/x") {
42+ t.Errorf("line 2 should contain cwd, got %q", lines[1])
43+ }
44+}
45+
46+// TestStatusBarNoCWD verifies an empty cwd stays single-line.
47+func TestStatusBarNoCWD(t *testing.T) {
48+ lines := StatusBar(StatusBarParams{
49+ Theme: Dark,
50+ Provider: "openai",
51+ Model: "gpt-5.4",
52+ CWD: "",
53+ Cols: 200,
54+ })
55+ if len(lines) != 1 {
56+ t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)
57+ }
58+}
M internal/tui/view.go +15 -30
@@ -771,15 +771,6 @@ func truncateLines(s string, n int) string {
771771 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
772772 }
773773
774-// StatusBar builds the single-line status shown above the editor.
775-//
776-// Layout:
777-//
778-// [busyPrefix] provider-model tokens-cost ctrl+c hint cwd
779-// ↑ right-aligned
780-//
781-// cols is the terminal width; when > 0 the cwd is placed flush-right
782-// with spaces. busyPrefix, if non-empty, is injected at the far left.
783774 // StatusBarParams groups the many bits of state the status bar needs.
784775 // Grew from a flat argument list once we started matching pi's format.
785776 type StatusBarParams struct {
@@ -811,23 +802,21 @@ type StatusBarParams struct {
811802 Cols int // terminal width; drives right-alignment of cwd
812803 }
813804
814-// StatusBar builds the status shown above the editor. Returns one or
815-// two lines depending on whether the (provider, model, stats, cwd)
816-// payload fits the terminal width.
805+// StatusBar builds the status shown above the editor. Always returns
806+// two lines when a cwd is provided: the stats on the first line, the
807+// cwd on its own line below, indented to match the stats column. This
808+// keeps the status bar stable across terminal resizes (the cwd never
809+// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
810+// makes a long cwd safe at any width.
817811 //
818812 // Layout:
819813 //
820-// <busyPrefix> (provider) model stats cwd <- one line if it fits
814+// <busyPrefix> (provider) model stats <- line 1
815+// cwd <- line 2 (2-space indent)
821816 //
822-// or, when it doesn't:
823-//
824-// <busyPrefix> (provider) model stats <- line 1
825-// cwd <- line 2
826-//
827-// The right-side gap is a fixed three spaces, not a stretched-to-fill
828-// fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone
829-// entirely — the slash-command popup and the queued/sliding-in chips
830-// already cover the discoverability of those keybindings.
817+// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
818+// The slash-command popup and the queued/sliding-in chips already
819+// cover the discoverability of those keybindings.
831820 func StatusBar(p StatusBarParams) []string {
832821 th := p.Theme
833822
@@ -903,14 +892,10 @@ func StatusBar(p StatusBarParams) []string {
903892 return []string{primary}
904893 }
905894
906- cwdRendered := th.FG256(th.Muted, cwd)
907- combined := primary + pad + cwdRendered
908-
909- // Wrap to a second line when the combined width would overflow.
910- if p.Cols > 0 && visibleWidth(combined) > p.Cols {
911- return []string{primary, cwdRendered}
912- }
913- return []string{combined}
895+ // Second line: indent with the same 2-space pad so the cwd lines
896+ // up under the "(provider)" column on line 1.
897+ cwdRendered := pad + th.FG256(th.Muted, cwd)
898+ return []string{primary, cwdRendered}
914899 }
915900
916901 // piContextUsage renders the "N%/ctxMax" fragment, returning the
@@ -771,15 +771,6 @@ func truncateLines(s string, n int) string {
771 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"771 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
772 }772 }
773 773
774-// StatusBar builds the single-line status shown above the editor.
775-//
776-// Layout:
777-//
778-// [busyPrefix] provider-model tokens-cost ctrl+c hint cwd
779-// ↑ right-aligned
780-//
781-// cols is the terminal width; when > 0 the cwd is placed flush-right
782-// with spaces. busyPrefix, if non-empty, is injected at the far left.
783 // StatusBarParams groups the many bits of state the status bar needs.774 // StatusBarParams groups the many bits of state the status bar needs.
784 // Grew from a flat argument list once we started matching pi's format.775 // Grew from a flat argument list once we started matching pi's format.
785 type StatusBarParams struct {776 type StatusBarParams struct {
@@ -811,23 +802,21 @@ type StatusBarParams struct {
811 Cols int // terminal width; drives right-alignment of cwd802 Cols int // terminal width; drives right-alignment of cwd
812 }803 }
813 804
814-// StatusBar builds the status shown above the editor. Returns one or805+// StatusBar builds the status shown above the editor. Always returns
815-// two lines depending on whether the (provider, model, stats, cwd)806+// two lines when a cwd is provided: the stats on the first line, the
816-// payload fits the terminal width.807+// cwd on its own line below, indented to match the stats column. This
808+// keeps the status bar stable across terminal resizes (the cwd never
809+// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
810+// makes a long cwd safe at any width.
817 //811 //
818 // Layout:812 // Layout:
819 //813 //
820-// <busyPrefix> (provider) model stats cwd <- one line if it fits814+// <busyPrefix> (provider) model stats <- line 1
815+// cwd <- line 2 (2-space indent)
821 //816 //
822-// or, when it doesn't:817+// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
823-//818+// The slash-command popup and the queued/sliding-in chips already
824-// <busyPrefix> (provider) model stats <- line 1819+// cover the discoverability of those keybindings.
825-// cwd <- line 2
826-//
827-// The right-side gap is a fixed three spaces, not a stretched-to-fill
828-// fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone
829-// entirely — the slash-command popup and the queued/sliding-in chips
830-// already cover the discoverability of those keybindings.
831 func StatusBar(p StatusBarParams) []string {820 func StatusBar(p StatusBarParams) []string {
832 th := p.Theme821 th := p.Theme
833 822
@@ -903,14 +892,10 @@ func StatusBar(p StatusBarParams) []string {
903 return []string{primary}892 return []string{primary}
904 }893 }
905 894
906- cwdRendered := th.FG256(th.Muted, cwd)895+ // Second line: indent with the same 2-space pad so the cwd lines
907- combined := primary + pad + cwdRendered896+ // up under the "(provider)" column on line 1.
908-897+ cwdRendered := pad + th.FG256(th.Muted, cwd)
909- // Wrap to a second line when the combined width would overflow.898+ return []string{primary, cwdRendered}
910- if p.Cols > 0 && visibleWidth(combined) > p.Cols {
911- return []string{primary, cwdRendered}
912- }
913- return []string{combined}
914 }899 }
915 900
916 // piContextUsage renders the "N%/ctxMax" fragment, returning the901 // piContextUsage renders the "N%/ctxMax" fragment, returning the
M pkg/zotext/zotext.go +180 -33
@@ -83,8 +83,65 @@ type Event struct {
8383 // InterceptHandler decides whether a tool call may proceed. Return
8484 // (allow=true) to permit, (allow=false, reason) to refuse. The
8585 // reason is shown to the model as the tool's error text.
86+//
87+// This is the original 2-value handler. For richer control (arg
88+// rewrites, turn_start / assistant_message interception, etc.)
89+// register via InterceptToolCallX, InterceptTurnStart, or
90+// InterceptAssistantMessage.
8691 type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)
8792
93+// ToolCallDecision is the richer reply an InterceptToolCallHandler
94+// can return. Zero value means "allow, pass through".
95+type ToolCallDecision struct {
96+ // Block refuses the call. The model sees Reason as the tool's
97+ // error text and typically proposes a different action.
98+ Block bool
99+ // Reason is the refusal text shown to the model on Block, or a
100+ // logged note when passing through (optional).
101+ Reason string
102+ // ModifiedArgs, when non-nil and Block is false, replaces the
103+ // JSON args the tool actually runs with. Lets guards redact /
104+ // augment / patch the model's request without rewriting the
105+ // transcript. Must encode to a JSON object.
106+ ModifiedArgs json.RawMessage
107+}
108+
109+// ToolCallHandler is the richer form of an interceptor. Use this
110+// when you want to rewrite args instead of only blocking.
111+type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
112+
113+// TurnStartDecision controls whether the next model call runs. Zero
114+// value means "allow".
115+type TurnStartDecision struct {
116+ Block bool
117+ Reason string
118+}
119+
120+// TurnStartHandler is called before every turn's model call. Return
121+// Block=true with Reason to abort the turn (shown to the user as a
122+// status line). Useful for rate-limiting and business-hour gates.
123+type TurnStartHandler func(step int) TurnStartDecision
124+
125+// AssistantMessageDecision controls the final assistant-text
126+// rendering. Zero value means "allow, show as-is".
127+type AssistantMessageDecision struct {
128+ // Block suppresses the message from the user entirely. The
129+ // transcript still records the model's original output so the
130+ // model sees what it said on the next turn.
131+ Block bool
132+ // Reason is logged when blocking (optional).
133+ Reason string
134+ // ReplaceText, when non-empty and Block is false, is what the
135+ // user sees. The model's original text still lives in the
136+ // transcript.
137+ ReplaceText string
138+}
139+
140+// AssistantMessageHandler is called after the model's final text is
141+// assembled but before it's shown. Use it to scrub secrets, expand
142+// templates, enforce tone, or suppress responses entirely.
143+type AssistantMessageHandler func(text string) AssistantMessageDecision
144+
88145 // ToolResult is the extension's reply to a tool invocation. Build
89146 // one with TextResult, ImageResult, or directly when you need to
90147 // combine multiple blocks.
@@ -178,8 +235,12 @@ type Extension struct {
178235 toolDefs []toolDef // ordered so register frames arrive in registration order
179236 eventHandlers map[string]EventHandler
180237 eventNames []string // declared subscription order
181- interceptTool InterceptHandler
182- interceptOn bool
238+
239+ interceptTool InterceptHandler
240+ interceptToolRich ToolCallHandler
241+ interceptOn bool
242+ interceptTurn TurnStartHandler
243+ interceptAssistant AssistantMessageHandler
183244
184245 // Caps reported in the hello frame.
185246 caps []string
@@ -285,6 +346,9 @@ func (e *Extension) On(name string, fn EventHandler) {
285346 // Use this to build permission gates: refuse `bash` calls containing
286347 // `rm -rf`, ask the user for confirmation on dangerous patterns,
287348 // audit-log every call, etc.
349+//
350+// For the richer form (arg rewrites, structured decisions) use
351+// InterceptToolCallX.
288352 func (e *Extension) InterceptToolCall(fn InterceptHandler) {
289353 e.mu.Lock()
290354 e.interceptTool = fn
@@ -292,6 +356,35 @@ func (e *Extension) InterceptToolCall(fn InterceptHandler) {
292356 e.mu.Unlock()
293357 }
294358
359+// InterceptToolCallX is the richer variant. Return a ToolCallDecision
360+// to block, allow, or rewrite args mid-flight. If both
361+// InterceptToolCall and InterceptToolCallX are set, the X form wins.
362+func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
363+ e.mu.Lock()
364+ e.interceptToolRich = fn
365+ e.interceptOn = true
366+ e.mu.Unlock()
367+}
368+
369+// InterceptTurnStart registers a guard that runs before every turn's
370+// model call. Return Block=true with Reason to abort the turn.
371+// Useful for deny-by-default gates and usage quotas.
372+func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
373+ e.mu.Lock()
374+ e.interceptTurn = fn
375+ e.mu.Unlock()
376+}
377+
378+// InterceptAssistantMessage registers a guard that runs after the
379+// model's final text is assembled but before it's shown. Return
380+// Block=true to suppress entirely, or ReplaceText to rewrite what
381+// the user sees. The model's original output stays in the transcript.
382+func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
383+ e.mu.Lock()
384+ e.interceptAssistant = fn
385+ e.mu.Unlock()
386+}
387+
295388 // Notify pushes an info-level status note into zot's chat without
296389 // requiring a slash command from the user.
297390 func (e *Extension) Notify(level, message string) {
@@ -320,7 +413,9 @@ func (e *Extension) Run() error {
320413 descs := append([]descTuple(nil), e.descriptions...)
321414 toolDefs := append([]toolDef(nil), e.toolDefs...)
322415 eventNames := append([]string(nil), e.eventNames...)
323- interceptOn := e.interceptOn
416+ interceptTool := e.interceptOn
417+ interceptTurn := e.interceptTurn != nil
418+ interceptAsst := e.interceptAssistant != nil
324419 e.mu.Unlock()
325420 for _, d := range descs {
326421 _ = e.send(extproto.RegisterCommandFromExt{
@@ -337,12 +432,22 @@ func (e *Extension) Run() error {
337432 Schema: td.schema,
338433 })
339434 }
340- if len(eventNames) > 0 || interceptOn {
341- sub := extproto.SubscribeFromExt{Type: "subscribe", Events: eventNames}
342- if interceptOn {
343- sub.Intercept = []string{"tool_call"}
344- }
345- _ = e.send(sub)
435+ var intercepts []string
436+ if interceptTool {
437+ intercepts = append(intercepts, "tool_call")
438+ }
439+ if interceptTurn {
440+ intercepts = append(intercepts, "turn_start")
441+ }
442+ if interceptAsst {
443+ intercepts = append(intercepts, "assistant_message")
444+ }
445+ if len(eventNames) > 0 || len(intercepts) > 0 {
446+ _ = e.send(extproto.SubscribeFromExt{
447+ Type: "subscribe",
448+ Events: eventNames,
449+ Intercept: intercepts,
450+ })
346451 }
347452 // Sentinel: tells the host that all initial registrations have
348453 // been flushed and the agent registry can be built. Never block
@@ -442,30 +547,7 @@ func (e *Extension) Run() error {
442547 if err := json.Unmarshal(line, &ei); err != nil {
443548 continue
444549 }
445- e.mu.Lock()
446- fn := e.interceptTool
447- e.mu.Unlock()
448- if fn == nil || ei.Event != "tool_call" {
449- _ = e.send(extproto.EventInterceptResponseFromExt{
450- Type: "event_intercept_response", ID: ei.ID,
451- })
452- continue
453- }
454- go func(id, name string, args json.RawMessage) {
455- defer func() {
456- if r := recover(); r != nil {
457- _ = e.send(extproto.EventInterceptResponseFromExt{
458- Type: "event_intercept_response", ID: id,
459- Block: true, Reason: fmt.Sprintf("intercept panic: %v", r),
460- })
461- }
462- }()
463- allow, reason := fn(name, args)
464- _ = e.send(extproto.EventInterceptResponseFromExt{
465- Type: "event_intercept_response", ID: id,
466- Block: !allow, Reason: reason,
467- })
468- }(ei.ID, ei.ToolName, ei.ToolArgs)
550+ go e.dispatchIntercept(ei)
469551 case "shutdown":
470552 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
471553 return nil
@@ -523,3 +605,68 @@ func (e *Extension) send(v any) error {
523605 _, err = e.out.Write(b)
524606 return err
525607 }
608+
609+// dispatchIntercept runs the per-event handler (tool_call / turn_start
610+// / assistant_message) on its own goroutine, catches panics, and
611+// always emits exactly one event_intercept_response. Called from the
612+// Run loop.
613+func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
614+ defer func() {
615+ if r := recover(); r != nil {
616+ _ = e.send(extproto.EventInterceptResponseFromExt{
617+ Type: "event_intercept_response",
618+ ID: ei.ID,
619+ Block: true,
620+ Reason: fmt.Sprintf("intercept panic: %v", r),
621+ })
622+ }
623+ }()
624+
625+ resp := extproto.EventInterceptResponseFromExt{
626+ Type: "event_intercept_response",
627+ ID: ei.ID,
628+ }
629+
630+ switch ei.Event {
631+ case "tool_call":
632+ e.mu.Lock()
633+ rich := e.interceptToolRich
634+ plain := e.interceptTool
635+ e.mu.Unlock()
636+ if rich != nil {
637+ d := rich(ei.ToolName, ei.ToolArgs)
638+ resp.Block = d.Block
639+ resp.Reason = d.Reason
640+ if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
641+ resp.ModifiedArgs = d.ModifiedArgs
642+ }
643+ } else if plain != nil {
644+ allow, reason := plain(ei.ToolName, ei.ToolArgs)
645+ resp.Block = !allow
646+ resp.Reason = reason
647+ }
648+ case "turn_start":
649+ e.mu.Lock()
650+ fn := e.interceptTurn
651+ e.mu.Unlock()
652+ if fn != nil {
653+ d := fn(ei.Step)
654+ resp.Block = d.Block
655+ resp.Reason = d.Reason
656+ }
657+ case "assistant_message":
658+ e.mu.Lock()
659+ fn := e.interceptAssistant
660+ e.mu.Unlock()
661+ if fn != nil {
662+ d := fn(ei.Text)
663+ resp.Block = d.Block
664+ resp.Reason = d.Reason
665+ if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
666+ resp.ReplaceText = d.ReplaceText
667+ }
668+ }
669+ }
670+
671+ _ = e.send(resp)
672+}
@@ -83,8 +83,65 @@ type Event struct {
83 // InterceptHandler decides whether a tool call may proceed. Return83 // InterceptHandler decides whether a tool call may proceed. Return
84 // (allow=true) to permit, (allow=false, reason) to refuse. The84 // (allow=true) to permit, (allow=false, reason) to refuse. The
85 // reason is shown to the model as the tool's error text.85 // reason is shown to the model as the tool's error text.
86+//
87+// This is the original 2-value handler. For richer control (arg
88+// rewrites, turn_start / assistant_message interception, etc.)
89+// register via InterceptToolCallX, InterceptTurnStart, or
90+// InterceptAssistantMessage.
86 type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)91 type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)
87 92
93+// ToolCallDecision is the richer reply an InterceptToolCallHandler
94+// can return. Zero value means "allow, pass through".
95+type ToolCallDecision struct {
96+ // Block refuses the call. The model sees Reason as the tool's
97+ // error text and typically proposes a different action.
98+ Block bool
99+ // Reason is the refusal text shown to the model on Block, or a
100+ // logged note when passing through (optional).
101+ Reason string
102+ // ModifiedArgs, when non-nil and Block is false, replaces the
103+ // JSON args the tool actually runs with. Lets guards redact /
104+ // augment / patch the model's request without rewriting the
105+ // transcript. Must encode to a JSON object.
106+ ModifiedArgs json.RawMessage
107+}
108+
109+// ToolCallHandler is the richer form of an interceptor. Use this
110+// when you want to rewrite args instead of only blocking.
111+type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
112+
113+// TurnStartDecision controls whether the next model call runs. Zero
114+// value means "allow".
115+type TurnStartDecision struct {
116+ Block bool
117+ Reason string
118+}
119+
120+// TurnStartHandler is called before every turn's model call. Return
121+// Block=true with Reason to abort the turn (shown to the user as a
122+// status line). Useful for rate-limiting and business-hour gates.
123+type TurnStartHandler func(step int) TurnStartDecision
124+
125+// AssistantMessageDecision controls the final assistant-text
126+// rendering. Zero value means "allow, show as-is".
127+type AssistantMessageDecision struct {
128+ // Block suppresses the message from the user entirely. The
129+ // transcript still records the model's original output so the
130+ // model sees what it said on the next turn.
131+ Block bool
132+ // Reason is logged when blocking (optional).
133+ Reason string
134+ // ReplaceText, when non-empty and Block is false, is what the
135+ // user sees. The model's original text still lives in the
136+ // transcript.
137+ ReplaceText string
138+}
139+
140+// AssistantMessageHandler is called after the model's final text is
141+// assembled but before it's shown. Use it to scrub secrets, expand
142+// templates, enforce tone, or suppress responses entirely.
143+type AssistantMessageHandler func(text string) AssistantMessageDecision
144+
88 // ToolResult is the extension's reply to a tool invocation. Build145 // ToolResult is the extension's reply to a tool invocation. Build
89 // one with TextResult, ImageResult, or directly when you need to146 // one with TextResult, ImageResult, or directly when you need to
90 // combine multiple blocks.147 // combine multiple blocks.
@@ -178,8 +235,12 @@ type Extension struct {
178 toolDefs []toolDef // ordered so register frames arrive in registration order235 toolDefs []toolDef // ordered so register frames arrive in registration order
179 eventHandlers map[string]EventHandler236 eventHandlers map[string]EventHandler
180 eventNames []string // declared subscription order237 eventNames []string // declared subscription order
181- interceptTool InterceptHandler238+
182- interceptOn bool239+ interceptTool InterceptHandler
240+ interceptToolRich ToolCallHandler
241+ interceptOn bool
242+ interceptTurn TurnStartHandler
243+ interceptAssistant AssistantMessageHandler
183 244
184 // Caps reported in the hello frame.245 // Caps reported in the hello frame.
185 caps []string246 caps []string
@@ -285,6 +346,9 @@ func (e *Extension) On(name string, fn EventHandler) {
285 // Use this to build permission gates: refuse `bash` calls containing346 // Use this to build permission gates: refuse `bash` calls containing
286 // `rm -rf`, ask the user for confirmation on dangerous patterns,347 // `rm -rf`, ask the user for confirmation on dangerous patterns,
287 // audit-log every call, etc.348 // audit-log every call, etc.
349+//
350+// For the richer form (arg rewrites, structured decisions) use
351+// InterceptToolCallX.
288 func (e *Extension) InterceptToolCall(fn InterceptHandler) {352 func (e *Extension) InterceptToolCall(fn InterceptHandler) {
289 e.mu.Lock()353 e.mu.Lock()
290 e.interceptTool = fn354 e.interceptTool = fn
@@ -292,6 +356,35 @@ func (e *Extension) InterceptToolCall(fn InterceptHandler) {
292 e.mu.Unlock()356 e.mu.Unlock()
293 }357 }
294 358
359+// InterceptToolCallX is the richer variant. Return a ToolCallDecision
360+// to block, allow, or rewrite args mid-flight. If both
361+// InterceptToolCall and InterceptToolCallX are set, the X form wins.
362+func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
363+ e.mu.Lock()
364+ e.interceptToolRich = fn
365+ e.interceptOn = true
366+ e.mu.Unlock()
367+}
368+
369+// InterceptTurnStart registers a guard that runs before every turn's
370+// model call. Return Block=true with Reason to abort the turn.
371+// Useful for deny-by-default gates and usage quotas.
372+func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
373+ e.mu.Lock()
374+ e.interceptTurn = fn
375+ e.mu.Unlock()
376+}
377+
378+// InterceptAssistantMessage registers a guard that runs after the
379+// model's final text is assembled but before it's shown. Return
380+// Block=true to suppress entirely, or ReplaceText to rewrite what
381+// the user sees. The model's original output stays in the transcript.
382+func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
383+ e.mu.Lock()
384+ e.interceptAssistant = fn
385+ e.mu.Unlock()
386+}
387+
295 // Notify pushes an info-level status note into zot's chat without388 // Notify pushes an info-level status note into zot's chat without
296 // requiring a slash command from the user.389 // requiring a slash command from the user.
297 func (e *Extension) Notify(level, message string) {390 func (e *Extension) Notify(level, message string) {
@@ -320,7 +413,9 @@ func (e *Extension) Run() error {
320 descs := append([]descTuple(nil), e.descriptions...)413 descs := append([]descTuple(nil), e.descriptions...)
321 toolDefs := append([]toolDef(nil), e.toolDefs...)414 toolDefs := append([]toolDef(nil), e.toolDefs...)
322 eventNames := append([]string(nil), e.eventNames...)415 eventNames := append([]string(nil), e.eventNames...)
323- interceptOn := e.interceptOn416+ interceptTool := e.interceptOn
417+ interceptTurn := e.interceptTurn != nil
418+ interceptAsst := e.interceptAssistant != nil
324 e.mu.Unlock()419 e.mu.Unlock()
325 for _, d := range descs {420 for _, d := range descs {
326 _ = e.send(extproto.RegisterCommandFromExt{421 _ = e.send(extproto.RegisterCommandFromExt{
@@ -337,12 +432,22 @@ func (e *Extension) Run() error {
337 Schema: td.schema,432 Schema: td.schema,
338 })433 })
339 }434 }
340- if len(eventNames) > 0 || interceptOn {435+ var intercepts []string
341- sub := extproto.SubscribeFromExt{Type: "subscribe", Events: eventNames}436+ if interceptTool {
342- if interceptOn {437+ intercepts = append(intercepts, "tool_call")
343- sub.Intercept = []string{"tool_call"}438+ }
344- }439+ if interceptTurn {
345- _ = e.send(sub)440+ intercepts = append(intercepts, "turn_start")
441+ }
442+ if interceptAsst {
443+ intercepts = append(intercepts, "assistant_message")
444+ }
445+ if len(eventNames) > 0 || len(intercepts) > 0 {
446+ _ = e.send(extproto.SubscribeFromExt{
447+ Type: "subscribe",
448+ Events: eventNames,
449+ Intercept: intercepts,
450+ })
346 }451 }
347 // Sentinel: tells the host that all initial registrations have452 // Sentinel: tells the host that all initial registrations have
348 // been flushed and the agent registry can be built. Never block453 // been flushed and the agent registry can be built. Never block
@@ -442,30 +547,7 @@ func (e *Extension) Run() error {
442 if err := json.Unmarshal(line, &ei); err != nil {547 if err := json.Unmarshal(line, &ei); err != nil {
443 continue548 continue
444 }549 }
445- e.mu.Lock()550+ go e.dispatchIntercept(ei)
446- fn := e.interceptTool
447- e.mu.Unlock()
448- if fn == nil || ei.Event != "tool_call" {
449- _ = e.send(extproto.EventInterceptResponseFromExt{
450- Type: "event_intercept_response", ID: ei.ID,
451- })
452- continue
453- }
454- go func(id, name string, args json.RawMessage) {
455- defer func() {
456- if r := recover(); r != nil {
457- _ = e.send(extproto.EventInterceptResponseFromExt{
458- Type: "event_intercept_response", ID: id,
459- Block: true, Reason: fmt.Sprintf("intercept panic: %v", r),
460- })
461- }
462- }()
463- allow, reason := fn(name, args)
464- _ = e.send(extproto.EventInterceptResponseFromExt{
465- Type: "event_intercept_response", ID: id,
466- Block: !allow, Reason: reason,
467- })
468- }(ei.ID, ei.ToolName, ei.ToolArgs)
469 case "shutdown":551 case "shutdown":
470 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})552 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
471 return nil553 return nil
@@ -523,3 +605,68 @@ func (e *Extension) send(v any) error {
523 _, err = e.out.Write(b)605 _, err = e.out.Write(b)
524 return err606 return err
525 }607 }
608+
609+// dispatchIntercept runs the per-event handler (tool_call / turn_start
610+// / assistant_message) on its own goroutine, catches panics, and
611+// always emits exactly one event_intercept_response. Called from the
612+// Run loop.
613+func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
614+ defer func() {
615+ if r := recover(); r != nil {
616+ _ = e.send(extproto.EventInterceptResponseFromExt{
617+ Type: "event_intercept_response",
618+ ID: ei.ID,
619+ Block: true,
620+ Reason: fmt.Sprintf("intercept panic: %v", r),
621+ })
622+ }
623+ }()
624+
625+ resp := extproto.EventInterceptResponseFromExt{
626+ Type: "event_intercept_response",
627+ ID: ei.ID,
628+ }
629+
630+ switch ei.Event {
631+ case "tool_call":
632+ e.mu.Lock()
633+ rich := e.interceptToolRich
634+ plain := e.interceptTool
635+ e.mu.Unlock()
636+ if rich != nil {
637+ d := rich(ei.ToolName, ei.ToolArgs)
638+ resp.Block = d.Block
639+ resp.Reason = d.Reason
640+ if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
641+ resp.ModifiedArgs = d.ModifiedArgs
642+ }
643+ } else if plain != nil {
644+ allow, reason := plain(ei.ToolName, ei.ToolArgs)
645+ resp.Block = !allow
646+ resp.Reason = reason
647+ }
648+ case "turn_start":
649+ e.mu.Lock()
650+ fn := e.interceptTurn
651+ e.mu.Unlock()
652+ if fn != nil {
653+ d := fn(ei.Step)
654+ resp.Block = d.Block
655+ resp.Reason = d.Reason
656+ }
657+ case "assistant_message":
658+ e.mu.Lock()
659+ fn := e.interceptAssistant
660+ e.mu.Unlock()
661+ if fn != nil {
662+ d := fn(ei.Text)
663+ resp.Block = d.Block
664+ resp.Reason = d.Reason
665+ if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
666+ resp.ReplaceText = d.ReplaceText
667+ }
668+ }
669+ }
670+
671+ _ = e.send(resp)
672+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close