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".
This commit is contained in:
patriceckhart 2026-04-19 17:02:04 +02:00
parent 9f031f941a
commit 99c9ba8062
15 changed files with 1203 additions and 145 deletions

View file

@ -195,6 +195,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
| `/compact` | Summarize the transcript into one message to free up context. |
| `/lock` | Confine tools to the current directory. |
| `/unlock` | Allow tools to touch paths outside again. |
| `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
| `/clear` | Clear the chat transcript. |
| `/exit` | Exit zot. |
@ -270,7 +271,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
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.
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.
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.
## Keys (interactive mode)
@ -306,7 +307,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
## Extensions
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.
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`.
### Install and manage

View file

@ -212,23 +212,51 @@ which it wants to intercept. Send once after `hello`, before `ready`.
```json
{"type":"subscribe",
"events":["session_start","turn_start","tool_call","turn_end","assistant_message"],
"intercept":["tool_call"]}
"intercept":["tool_call","turn_start","assistant_message"]}
```
Recognised event names: `session_start`, `turn_start`, `turn_end`,
`tool_call`, `assistant_message`. Only `tool_call` is interceptable
in this version; other names listed under `intercept` are ignored.
`tool_call`, `assistant_message`.
Interceptable events:
- `tool_call`: block the call (model sees `reason` as the tool
error) or rewrite args via `modified_args`.
- `turn_start`: block the turn before the model is called. Useful
for rate-limiting and business-hour gates. `reason` is shown to
the user as a status line. No rewrite supported.
- `assistant_message`: suppress the message via `block`, or rewrite
the user-visible text via `replace_text`. The model's original
text stays in the transcript so the model sees what it actually
said on subsequent turns.
#### `event_intercept_response`
Reply to an `event_intercept` from the host. `block: true` refuses
the action; `reason` is shown to the model as the tool error text.
Reply to an `event_intercept` from the host. All fields default to
"allow, pass through unmodified".
| field | meaning |
|---|---|
| `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. |
| `reason` | refusal text (on block) or pass-through note. |
| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
| `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. |
Missing the response within 5s is treated as "allow" (i.e. an
unresponsive extension never stalls the agent).
unresponsive extension never stalls the agent). When multiple
extensions subscribe to the same event, they're consulted serially;
the first `block` wins and rewrites (args / text) chain: each
subsequent interceptor sees the previous one's output.
```json
{"type":"event_intercept_response","id":"...",
"block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
{"type":"event_intercept_response","id":"...",
"modified_args":{"command":"echo GUARDED: ls"}}
{"type":"event_intercept_response","id":"...",
"replace_text":"[redacted]"}
```
#### `command_response` (reply to `command_invoked`)
@ -319,17 +347,26 @@ Lifecycle notification for events the extension subscribed to via
#### `event_intercept`
Sent when zot wants to give the extension a chance to block a
lifecycle event before it happens. Same payload shape as `event`.
Reply with `event_intercept_response` within 5s; missing the deadline
is treated as "allow".
Sent when zot wants to give the extension a chance to block, modify,
or annotate a lifecycle event before it happens. Reply with
`event_intercept_response` within 5s; missing the deadline is
treated as "allow".
Only `event: "tool_call"` is sent in this version.
Payload fields depend on the event:
```json
// tool_call: includes the tool id, name, and parsed args
{"type":"event_intercept","id":"...","event":"tool_call",
"tool_id":"...","tool_name":"bash",
"tool_args":{"command":"rm -rf /tmp/foo"}}
// turn_start: includes the step number
{"type":"event_intercept","id":"...","event":"turn_start",
"step":3}
// assistant_message: includes the assembled text
{"type":"event_intercept","id":"...","event":"assistant_message",
"text":"here is your api key: sk-ant-..."}
```
#### `shutdown`
@ -406,6 +443,37 @@ func main() {
Build with `go build -o hello .`, drop the binary + an `extension.json`
into `$ZOT_HOME/extensions/hello/`.
The SDK has four interceptor hooks, all optional:
```go
// Refuse calls or rewrite args before they run.
ext.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
if tool == "bash" { /* inspect args, return false, reason */ }
return true, ""
})
// Richer variant: returns ToolCallDecision so you can also rewrite
// args via ModifiedArgs.
ext.InterceptToolCallX(func(tool string, args json.RawMessage) zotext.ToolCallDecision {
return zotext.ToolCallDecision{
ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
}
})
// Block the next turn before the model is called.
ext.InterceptTurnStart(func(step int) zotext.TurnStartDecision {
if time.Now().Hour() < 9 { return zotext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
return zotext.TurnStartDecision{}
})
// Scrub or rewrite the assistant's final text before the user sees it.
ext.InterceptAssistantMessage(func(text string) zotext.AssistantMessageDecision {
return zotext.AssistantMessageDecision{
ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
}
})
```
See:
- `examples/extensions/hello/` — slash commands
- `examples/extensions/clock/` — slash commands in plain Node, no SDK
@ -413,6 +481,16 @@ See:
- `examples/extensions/guard/` — event subscriptions + tool-call
interception (refuses dangerous bash patterns)
### Hot reload
Type `/reload-ext` in the TUI to tear down every running extension
subprocess, re-read the manifests from disk, and respawn the set.
The agent's tool registry is rebuilt automatically, so freshly-
registered extension tools become callable without restarting zot.
Useful while developing an extension: edit, save, `/reload-ext`,
done. Explicit `--ext` paths are remembered and reloaded alongside
discovered extensions.
### TypeScript / Python
These SDKs aren't in the main repo yet; the wire format is small
@ -449,7 +527,15 @@ Phase 3 (shipped):
`tool_call`, `assistant_message`)
- [x] tool-call interception (block before execution)
Phase 4 (shipped):
- [x] interception for `turn_start` and `assistant_message` (in
addition to `tool_call`)
- [x] modify tool args mid-flight via `modified_args`
- [x] rewrite user-visible assistant text via `replace_text`
- [x] `/reload-ext` slash command (hot-reload without restarting zot)
Future (no firm timeline):
- [ ] interception for additional events beyond `tool_call`
- [ ] modify (not just block) tool args mid-flight
- [ ] `/reload-ext` slash command (hot-reload without restarting zot)
- [ ] TypeScript and Python SDK packages (currently the wire format
is stable enough to hand-roll, see the Python quick-start)
- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
- [ ] per-extension permission scopes (today: full user privileges)

View file

@ -3,6 +3,7 @@ package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
@ -288,8 +289,23 @@ func runInteractive(ctx context.Context, args Args, version string) error {
if a == nil {
return a
}
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {
return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
if r.Block {
return false, r.Reason, nil
}
return true, "", r.ModifiedArgs
}
a.BeforeTurn = func(step int) (bool, string) {
r := extMgr.InterceptTurnStart(ctx, step)
return !r.Block, r.Reason
}
a.BeforeAssistantMessage = func(text string) (bool, string, string) {
r := extMgr.InterceptAssistantMessage(ctx, text)
if r.Block {
return false, r.Reason, ""
}
return true, "", r.ReplaceText
}
a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
return a
@ -328,6 +344,26 @@ func runInteractive(ctx context.Context, args Args, version string) error {
ag = wireAgentExt(r.NewAgent())
}
// /reload-ext callback: after the manager has respawned every
// extension, re-resolve the tool registry (built-ins + freshly-
// registered extension tools) and swap it onto the current
// agent in-place. The current agent may have been replaced by a
// /model swap since spawn, so re-read the live `ag` on each
// invocation.
extMgr.SetOnReload(func() {
current := ag
if current == nil {
return
}
resolved, err := Resolve(args, true)
if err != nil {
return
}
resolved.UseSandbox(sharedSandbox)
resolved.MergeExtensionTools(extToolAdapter)
current.SetTools(resolved.ToolRegistry)
})
// Fire session_start once we know the manager's running.
extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})

View file

@ -46,77 +46,192 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
}
}
// InterceptToolCall asks every extension that subscribed to tool_call
// interception whether the call may proceed. Subscribers are invoked
// serially; the first one to return Block: true wins, with its Reason
// surfaced as the refusal text. allowed=true means proceed.
//
// Each subscriber gets up to interceptTimeout to reply; missing the
// deadline counts as "allow" (i.e. an unresponsive interceptor never
// stalls the agent).
func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {
// InterceptResult aggregates the outcome of walking every subscribed
// interceptor for one event. The zero value means "allow, no
// modification". Callers check Block first; if allowed, use the
// optional rewrite fields (ModifiedArgs for tool_call, ReplaceText
// for assistant_message) to carry the rewrite into the action.
type InterceptResult struct {
Block bool
Reason string
ModifiedArgs json.RawMessage
ReplaceText string
}
const interceptTimeout = 5 * time.Second
// InterceptToolCall is the typed entry point for tool_call
// interception. Subscribers are invoked serially; the first to return
// Block=true wins. Rewrites (ModifiedArgs) from earlier subscribers
// flow into later ones, so a chain of guards can successively redact
// / patch the args.
func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string, args json.RawMessage) InterceptResult {
subs := m.interceptSubsFor("tool_call")
if len(subs) == 0 {
return InterceptResult{}
}
current := args
for _, ext := range subs {
r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
Event: "tool_call",
ToolID: toolID,
ToolName: toolName,
ToolArgs: current,
})
if r.Block {
return r
}
if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
current = r.ModifiedArgs
}
}
out := InterceptResult{}
if !jsonEqual(current, args) {
out.ModifiedArgs = current
}
return out
}
// InterceptTurnStart asks every subscriber whether the upcoming turn
// may run. Block=true aborts the turn with Reason shown to the user.
// Rewrites are not supported for this event.
func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptResult {
subs := m.interceptSubsFor("turn_start")
if len(subs) == 0 {
return InterceptResult{}
}
for _, ext := range subs {
r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
Event: "turn_start",
Step: step,
})
if r.Block {
return r
}
}
return InterceptResult{}
}
// InterceptAssistantMessage asks every subscriber to approve, block,
// or rewrite the assistant's final visible text. Block=true hides the
// message from the user entirely; a non-empty ReplaceText rewrites
// what the user sees while keeping the model's original text in the
// transcript. Successive rewrites chain: each subscriber sees the
// previous subscriber's output.
func (m *Manager) InterceptAssistantMessage(ctx context.Context, text string) InterceptResult {
subs := m.interceptSubsFor("assistant_message")
if len(subs) == 0 {
return InterceptResult{}
}
current := text
for _, ext := range subs {
r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
Event: "assistant_message",
Text: current,
})
if r.Block {
return r
}
if r.ReplaceText != "" {
current = r.ReplaceText
}
}
out := InterceptResult{}
if current != text {
out.ReplaceText = current
}
return out
}
// interceptSubsFor returns the snapshot of extensions that subscribed
// to intercepting the named event.
func (m *Manager) interceptSubsFor(event string) []*Extension {
m.mu.RLock()
defer m.mu.RUnlock()
subs := make([]*Extension, 0, len(m.ext))
for _, ext := range m.ext {
ext.mu.Lock()
_, subscribed := ext.interceptSubs["tool_call"]
_, subscribed := ext.interceptSubs[event]
ext.mu.Unlock()
if subscribed {
subs = append(subs, ext)
}
}
m.mu.RUnlock()
if len(subs) == 0 {
return true, ""
}
for _, ext := range subs {
ok, reason := m.askIntercept(ctx, ext, toolID, toolName, args)
if !ok {
return false, reason
}
}
return true, ""
return subs
}
const interceptTimeout = 5 * time.Second
func (m *Manager) askIntercept(ctx context.Context, ext *Extension, toolID, toolName string, args json.RawMessage) (allowed bool, reason string) {
// askIntercept sends one EventInterceptFromHost to ext and waits for
// the reply, a timeout, or context cancellation. Returns a typed
// result. Never blocks for longer than interceptTimeout.
func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extproto.EventInterceptFromHost) InterceptResult {
id := newCorrelationID()
ch := make(chan extproto.EventInterceptResponseFromExt, 1)
ext.mu.Lock()
ext.pendingIntercept[id] = ch
ext.mu.Unlock()
frame, _ := extproto.Encode(extproto.EventInterceptFromHost{
Type: "event_intercept",
ID: id,
Event: "tool_call",
ToolID: toolID,
ToolName: toolName,
ToolArgs: args,
})
payload.Type = "event_intercept"
payload.ID = id
frame, err := extproto.Encode(payload)
if err != nil {
ext.mu.Lock()
delete(ext.pendingIntercept, id)
ext.mu.Unlock()
return InterceptResult{}
}
if _, err := ext.stdin.Write(frame); err != nil {
ext.mu.Lock()
delete(ext.pendingIntercept, id)
ext.mu.Unlock()
fmt.Fprintf(ext.logFile, "[zot] intercept write failed: %v\n", err)
return true, ""
return InterceptResult{}
}
select {
case resp := <-ch:
return !resp.Block, resp.Reason
return InterceptResult{
Block: resp.Block,
Reason: resp.Reason,
ModifiedArgs: resp.ModifiedArgs,
ReplaceText: resp.ReplaceText,
}
case <-time.After(interceptTimeout):
ext.mu.Lock()
delete(ext.pendingIntercept, id)
ext.mu.Unlock()
fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", toolName)
return true, ""
fmt.Fprintf(ext.logFile, "[zot] intercept %s timed out; allowing\n", payload.Event)
return InterceptResult{}
case <-ctx.Done():
ext.mu.Lock()
delete(ext.pendingIntercept, id)
ext.mu.Unlock()
return true, ""
return InterceptResult{}
}
}
// jsonEqual reports whether a and b encode the same JSON value. Used
// to detect whether the interceptor chain actually mutated the args.
func jsonEqual(a, b json.RawMessage) bool {
if len(a) == len(b) {
same := true
for i := range a {
if a[i] != b[i] {
same = false
break
}
}
if same {
return true
}
}
// Fallback to structural compare so whitespace differences don't
// register as a mutation. (We don't actually rely on this to be
// cheap, callers only use it to pick a code path.)
var av, bv any
if json.Unmarshal(a, &av) != nil || json.Unmarshal(b, &bv) != nil {
return false
}
ae, _ := json.Marshal(av)
be, _ := json.Marshal(bv)
return string(ae) == string(be)
}

View file

@ -0,0 +1,196 @@
package extensions
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestInterceptAllThreeEvents exercises tool_call / turn_start /
// assistant_message interception end-to-end via a bash extension that
// blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,
// suppresses nothing at turn_start, and rewrites SECRET → [redacted]
// in assistant messages.
//
// Validates: block, modified_args, pass-through, and replace_text.
func TestInterceptAllThreeEvents(t *testing.T) {
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("no /bin/bash")
}
extDir := t.TempDir()
script := `#!/bin/bash
emit() { printf '%s\n' "$1"; }
emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}'
emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}'
emit '{"type":"ready"}'
while IFS= read -r line; do
t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
id=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
ev=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("event",""))')
if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
if [[ "$t" != "event_intercept" ]]; then continue; fi
case "$ev" in
tool_call)
name=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))')
args=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("tool_args",{})))')
cmd=$(printf '%s' "$args" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("command",""))')
if [[ "$name" == "bash" && "$cmd" == *"rm -rf"* ]]; then
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}"
elif [[ "$name" == "bash" && -n "$cmd" ]]; then
new=$(python3 -c "import json,sys;print(json.dumps({'command':'echo GUARDED: '+sys.argv[1]}))" "$cmd")
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"modified_args\":$new}"
else
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
fi ;;
turn_start)
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" ;;
assistant_message)
text=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("text",""))')
if [[ "$text" == *"SECRET"* ]]; then
new=$(python3 -c "import sys;print(sys.argv[1].replace('SECRET','[redacted]'))" "$text")
rt=$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$new")
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"replace_text\":$rt}"
else
emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}"
fi ;;
esac
done
`
if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"itest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
// ZotHome is unused here; we load the extension explicitly.
m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
t.Cleanup(func() { m.Stop(2 * time.Second) })
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
t.Fatalf("LoadExplicit: %v", errs)
}
m.WaitForReady(3 * time.Second)
// tool_call: rm -rf is blocked
res := m.InterceptToolCall(ctx, "T1", "bash", json.RawMessage(`{"command":"rm -rf /tmp/foo"}`))
if !res.Block || !strings.Contains(res.Reason, "refused") {
t.Errorf("rm -rf: want block+reason, got %+v", res)
}
// tool_call: non-dangerous bash gets args rewritten
res = m.InterceptToolCall(ctx, "T2", "bash", json.RawMessage(`{"command":"ls -la"}`))
if res.Block {
t.Errorf("ls -la: want allow, got block %q", res.Reason)
}
if len(res.ModifiedArgs) == 0 {
t.Errorf("ls -la: want modified_args, got nothing")
} else {
var obj map[string]string
if err := json.Unmarshal(res.ModifiedArgs, &obj); err != nil {
t.Errorf("modified_args: %v", err)
} else if !strings.HasPrefix(obj["command"], "echo GUARDED") {
t.Errorf("modified_args command=%q, want echo GUARDED prefix", obj["command"])
}
}
// tool_call: non-bash tool untouched
res = m.InterceptToolCall(ctx, "T3", "read", json.RawMessage(`{"path":"/etc/hosts"}`))
if res.Block {
t.Errorf("read: want allow, got block %q", res.Reason)
}
if len(res.ModifiedArgs) != 0 {
t.Errorf("read: want no modified_args, got %s", string(res.ModifiedArgs))
}
// turn_start: always allowed
if r := m.InterceptTurnStart(ctx, 1); r.Block {
t.Errorf("turn_start: want allow, got block %q", r.Reason)
}
// assistant_message: SECRET gets redacted
r := m.InterceptAssistantMessage(ctx, "your password is SECRET123")
if r.Block {
t.Errorf("msg: want allow, got block %q", r.Reason)
}
if !strings.Contains(r.ReplaceText, "[redacted]") {
t.Errorf("msg: want [redacted] in replacement, got %q", r.ReplaceText)
}
// assistant_message: clean text unchanged
r = m.InterceptAssistantMessage(ctx, "hello world")
if r.Block {
t.Errorf("clean: want allow")
}
if r.ReplaceText != "" {
t.Errorf("clean: want no replace, got %q", r.ReplaceText)
}
}
// TestReloadRespawnsExtensions verifies Reload tears down and brings
// back the same extension, and the onReload callback fires.
func TestReloadRespawnsExtensions(t *testing.T) {
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("no /bin/bash")
}
extDir := t.TempDir()
script := `#!/bin/bash
emit() { printf '%s\n' "$1"; }
emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}'
emit '{"type":"register_command","name":"rtest","description":"test"}'
emit '{"type":"ready"}'
while IFS= read -r line; do
t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))')
if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi
done
`
if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"rtest","version":"0.1.0","exec":"./ext.sh","enabled":true}`
if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
t.Cleanup(func() { m.Stop(2 * time.Second) })
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if errs := m.LoadExplicit(ctx, []string{extDir}); len(errs) > 0 {
t.Fatalf("LoadExplicit: %v", errs)
}
m.WaitForReady(3 * time.Second)
before := len(m.All())
if before == 0 {
t.Fatal("extension didn't load")
}
reloadFired := false
m.SetOnReload(func() { reloadFired = true })
stats := m.Reload(ctx, 2*time.Second)
if stats.Stopped != before {
t.Errorf("Stopped: want %d, got %d", before, stats.Stopped)
}
if stats.Loaded != before {
t.Errorf("Loaded: want %d, got %d", before, stats.Loaded)
}
if !reloadFired {
t.Error("onReload callback didn't fire")
}
// Registered command should still be there after reload.
if !m.HasCommand("rtest") {
t.Error("rtest command not re-registered after reload")
}
}

View file

@ -132,6 +132,15 @@ type Manager struct {
// toolIndex maps an extension-defined tool name to its owning
// extension. Same first-come-first-served rule as commandIndex.
toolIndex map[string]*Extension
// explicitPaths remembers ad-hoc paths passed via --ext so
// Reload can respawn them alongside the discovered set.
explicitPaths []string
// onReload, if set, is invoked after a successful Reload. Used
// by the host so it can rebuild the agent's tool registry with
// the freshly-registered extension tools.
onReload func()
}
// 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 {
var wg sync.WaitGroup
errCh := make(chan error, len(paths))
absPaths := make([]string, 0, len(paths))
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
errCh <- fmt.Errorf("%s: %w", p, err)
continue
}
absPaths = append(absPaths, abs)
wg.Add(1)
go func(extDir string) {
defer wg.Done()
@ -300,6 +311,10 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
wg.Wait()
close(errCh)
m.mu.Lock()
m.explicitPaths = append(m.explicitPaths, absPaths...)
m.mu.Unlock()
var errs []error
for e := range errCh {
errs = append(errs, e)
@ -307,6 +322,119 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
return errs
}
// SetOnReload registers a callback fired after a successful Reload.
// Hosts use it to rebuild the agent's tool registry with freshly-
// registered extension tools.
func (m *Manager) SetOnReload(fn func()) {
m.mu.Lock()
m.onReload = fn
m.mu.Unlock()
}
// ReloadStats summarises the outcome of Reload.
type ReloadStats struct {
Stopped int // how many old processes were torn down
Loaded int // how many new processes reached spawn
Ready int // how many of those signalled ready in time
Errors []error // non-fatal per-extension errors
}
// Reload tears down every running extension, re-reads the manifests
// from disk, respawns everyone (including the --ext paths remembered
// from LoadExplicit), waits up to grace for ready signals, and
// invokes the SetOnReload callback so the host can rebuild its tool
// registry. The manager's internal maps are cleared before the
// new load to ensure a clean slate.
//
// Safe to call concurrently with normal host operations: the lock is
// released between stop and respawn so pending InvokeTool / Invoke
// calls on the old processes get a clean error as their stdin
// closes.
func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
stats := ReloadStats{}
// Snapshot and remember the explicit paths before we wipe state.
m.mu.Lock()
old := m.ext
explicit := append([]string(nil), m.explicitPaths...)
stats.Stopped = len(old)
m.ext = map[string]*Extension{}
m.commandIndex = map[string]*Extension{}
m.toolIndex = map[string]*Extension{}
m.explicitPaths = nil
callback := m.onReload
m.mu.Unlock()
// Graceful stop of the old set (reuses Stop's shutdown logic,
// but Stop re-reads m.ext which is now empty, so we replicate
// the small shutdown loop here on the snapshot).
for _, ext := range old {
if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
_, _ = ext.stdin.Write(frame)
}
_ = ext.stdin.Close()
}
deadline := time.Now().Add(grace)
for _, ext := range old {
remaining := time.Until(deadline)
if remaining <= 0 {
remaining = 100 * time.Millisecond
}
done := make(chan struct{})
go func(e *Extension) { _ = e.cmd.Wait(); close(done) }(ext)
select {
case <-done:
case <-time.After(remaining):
_ = ext.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-done:
case <-time.After(time.Second):
_ = ext.cmd.Process.Kill()
<-done
}
}
if ext.logFile != nil {
_ = ext.logFile.Close()
}
}
// Fresh load. Explicit paths first (they still win on conflict).
if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
stats.Errors = append(stats.Errors, errs...)
}
if errs := m.Discover(ctx); len(errs) > 0 {
stats.Errors = append(stats.Errors, errs...)
}
m.mu.RLock()
stats.Loaded = len(m.ext)
m.mu.RUnlock()
// Wait for ready frames. Use the same 3s grace zot uses at
// startup so the reload feels no slower than a cold boot.
readyDeadline := time.Now().Add(grace)
if time.Until(readyDeadline) < 3*time.Second {
readyDeadline = time.Now().Add(3 * time.Second)
}
m.WaitForReady(time.Until(readyDeadline))
m.mu.RLock()
for _, ext := range m.ext {
select {
case <-ext.readyCh:
stats.Ready++
default:
}
}
m.mu.RUnlock()
if callback != nil {
callback()
}
return stats
}
// WaitForReady blocks until every loaded extension has signalled
// ReadyFromExt, or the grace period expires for the slowest one.
//
@ -551,7 +679,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
ext.eventSubs[ev] = struct{}{}
}
for _, ev := range sub.Intercept {
if ev == "tool_call" { // only kind supported in v1
switch ev {
case "tool_call", "turn_start", "assistant_message":
ext.interceptSubs[ev] = struct{}{}
}
}

View file

@ -1207,6 +1207,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
i.statusOK = "unlocked"
i.statusErr = ""
i.mu.Unlock()
case "/reload-ext":
i.runReloadExt(ctx)
default:
// Last-resort fallback: try the extension manager. Built-in
// cases above always win; this branch only fires for slash
@ -1944,3 +1946,35 @@ func shortArgs(raw json.RawMessage) string {
// silence unused import in some build configs
var _ = fmt.Sprintf
// runReloadExt triggers a live reload of every extension (discovered
// + explicit). Runs on a goroutine so the TUI stays responsive; the
// Manager.Reload takes a couple of hundred ms to shut down subprocs
// and respawn them. Shows a status line throughout.
func (i *Interactive) runReloadExt(ctx context.Context) {
if i.cfg.Extensions == nil {
i.mu.Lock()
i.statusErr = "no extension manager in this build"
i.mu.Unlock()
i.invalidate()
return
}
i.mu.Lock()
i.statusOK = "reloading extensions…"
i.statusErr = ""
i.mu.Unlock()
i.invalidate()
go func() {
stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
if len(stats.Errors) > 0 {
msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
}
i.mu.Lock()
i.statusOK = msg
i.statusErr = ""
i.mu.Unlock()
i.invalidate()
}()
}

View file

@ -23,7 +23,7 @@ type slashCommand struct {
// the streaming response without trouble.
func slashCancelsTurn(head string) bool {
switch head {
case "/clear", "/compact", "/logout", "/login", "/model":
case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext":
return true
}
return false
@ -43,6 +43,7 @@ var slashCatalog = []slashCommand{
{Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
{Name: "/lock", Desc: "confine tools to the current directory"},
{Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
{Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
{Name: "/clear", Desc: "clear the chat transcript"},
{Name: "/exit", Desc: "exit zot"},
}

View file

@ -63,10 +63,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
ag := r.NewAgent()
ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {
return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
if r.Block {
return false, r.Reason, nil
}
return true, "", r.ModifiedArgs
}
ag.BeforeTurn = func(step int) (bool, string) {
r := extMgr.InterceptTurnStart(ctx, step)
return !r.Block, r.Reason
}
ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
r := extMgr.InterceptAssistantMessage(ctx, text)
if r.Block {
return false, r.Reason, ""
}
return true, "", r.ReplaceText
}
ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
// /reload-ext hot-reload callback (also triggered via rpc
// `reload_ext` if/when added). Rebuilds the tool registry on the
// current agent so freshly-registered extension tools become
// callable without restarting the rpc process.
adapter := &extToolAdapter{mgr: extMgr}
extMgr.SetOnReload(func() {
resolved, err := Resolve(args, true)
if err != nil {
return
}
resolved.MergeExtensionTools(adapter)
ag.SetTools(resolved.ToolRegistry)
})
extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
server := &rpcServer{

View file

@ -23,10 +23,27 @@ type Agent struct {
// BeforeToolExecute, if set, is called immediately before each
// tool runs. Returning (allowed=false, reason) short-circuits
// the call with an error result containing reason. Used by the
// extension manager's tool-call interception. Always treated as
// (allowed=true, "") when nil.
BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string)
// the call with an error result containing reason. Optionally,
// returning a non-nil modifiedArgs replaces the JSON args the
// tool will see, which lets guards redact / augment / patch the
// model's request without rewriting the transcript. Empty or
// malformed modifiedArgs is ignored.
BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
// BeforeTurn, if set, is called before each turn's model call.
// Returning (allowed=false, reason) aborts the turn; reason is
// surfaced as an assistant-like status line. Used for rate-
// limiting, business-hour gates, and deny-by-default setups.
BeforeTurn func(step int) (allowed bool, reason string)
// BeforeAssistantMessage, if set, is called after the model's
// final assistant message is assembled but before it's appended
// to the transcript. Returning (allowed=false) suppresses both
// the transcript append and the UI event. A non-empty
// replacement rewrites the visible text for the user while
// leaving the model's original text in the transcript (so the
// model can still see what it said in subsequent turns).
BeforeAssistantMessage func(text string) (allowed bool, reason, replacement string)
// OnEvent, if set, mirrors every AgentEvent the loop emits to
// this callback in addition to the per-Prompt sink. Used by the
@ -59,6 +76,15 @@ func (a *Agent) Messages() []provider.Message {
return out
}
// SetTools swaps the tool registry. Used by /reload-ext to hand
// the agent a fresh registry after extension subprocesses have been
// respawned (and their freshly-registered tools merged in).
func (a *Agent) SetTools(reg Registry) {
a.mu.Lock()
a.Tools = reg
a.mu.Unlock()
}
// SetMessages replaces the transcript (used when resuming a session).
func (a *Agent) SetMessages(msgs []provider.Message) {
a.mu.Lock()
@ -134,6 +160,16 @@ func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
for step := 1; step <= a.MaxSteps; step++ {
sink(EvTurnStart{Step: step})
if a.BeforeTurn != nil {
if allowed, reason := a.BeforeTurn(step); !allowed {
if reason == "" {
reason = "turn blocked by extension guard"
}
sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
sink(EvDone{})
return nil
}
}
stop, assistantMsg, err := a.oneTurn(ctx, sink)
sink(EvTurnEnd{Stop: stop, Err: err})
if err != nil {
@ -208,12 +244,33 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
// Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
// produce partial, mid-sentence content that would confuse subsequent
// turns if it stayed in the transcript drop it instead.
// turns if it stayed in the transcript, drop it instead.
if len(finalMsg.Content) > 0 && stop != provider.StopAborted {
emit := finalMsg
suppress := false
// BeforeAssistantMessage hook: extensions can suppress or
// rewrite the visible text. The transcript keeps the
// model's original output so the model still sees what it
// said on subsequent turns.
if a.BeforeAssistantMessage != nil {
orig := extractText(finalMsg)
if orig != "" {
allowed, _, replacement := a.BeforeAssistantMessage(orig)
if !allowed {
suppress = true
} else if replacement != "" && replacement != orig {
emit = replaceText(finalMsg, replacement)
}
}
}
a.mu.Lock()
a.messages = append(a.messages, finalMsg)
a.mu.Unlock()
sink(EvAssistantMessage{Message: finalMsg})
if !suppress {
sink(EvAssistantMessage{Message: emit})
}
// Now surface tool calls as EvToolCall events so UIs can render them
// in order before the tool results arrive.
for _, c := range finalMsg.Content {
@ -265,12 +322,17 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
}
}
args := tc.Arguments
// Intercept hook: an extension or other guard can refuse the
// call before any side effect happens. The model sees the
// reason as the tool error, learns from it, and (typically)
// proposes a different action.
// call before any side effect happens, OR rewrite the args
// seen by the tool. The model sees the reason as the tool
// error, learns from it, and (typically) proposes a different
// action; rewrites are invisible to the model (they apply only
// to the execution).
if a.BeforeToolExecute != nil {
if allowed, reason := a.BeforeToolExecute(tc); !allowed {
allowed, reason, modified := a.BeforeToolExecute(tc)
if !allowed {
if reason == "" {
reason = "tool call refused by extension guard"
}
@ -279,9 +341,11 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
IsError: true,
}
}
if len(modified) > 0 && json.Valid(modified) {
args = modified
}
}
args := tc.Arguments
if len(args) == 0 {
args = json.RawMessage("{}")
}
@ -318,3 +382,42 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink
}()
return res
}
// extractText concatenates all TextBlock content in a message. Used
// by BeforeAssistantMessage so guards see a single string instead of
// having to walk provider.Content themselves.
func extractText(msg provider.Message) string {
var out string
for _, c := range msg.Content {
if tb, ok := c.(provider.TextBlock); ok {
if out != "" {
out += "\n"
}
out += tb.Text
}
}
return out
}
// replaceText returns a copy of msg with every TextBlock replaced by
// a single TextBlock containing replacement. Non-text content (tool
// calls, etc.) is preserved in order.
func replaceText(msg provider.Message, replacement string) provider.Message {
out := provider.Message{Role: msg.Role}
out.Content = make([]provider.Content, 0, len(msg.Content))
replaced := false
for _, c := range msg.Content {
if _, ok := c.(provider.TextBlock); ok {
if !replaced {
out.Content = append(out.Content, provider.TextBlock{Text: replacement})
replaced = true
}
continue
}
out.Content = append(out.Content, c)
}
if !replaced {
out.Content = append(out.Content, provider.TextBlock{Text: replacement})
}
return out
}

View file

@ -0,0 +1,109 @@
package core
import (
"context"
"encoding/json"
"testing"
"github.com/patriceckhart/zot/internal/provider"
)
// recordingTool captures the args it was invoked with so the test
// can verify the interceptor-rewritten args reached execution.
type recordingTool struct {
lastArgs json.RawMessage
}
func (r *recordingTool) Name() string { return "echo" }
func (r *recordingTool) Description() string { return "echoes" }
func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
r.lastArgs = append(json.RawMessage(nil), args...)
return ToolResult{
Content: []provider.Content{provider.TextBlock{Text: "ok"}},
}, nil
}
// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
// modifiedArgs returned from BeforeToolExecute is what the tool
// actually sees.
func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
rec := &recordingTool{}
reg := Registry{"echo": rec}
a := NewAgent(nil, "test", "", reg)
newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
return true, "", newArgs
}
ctx := context.Background()
res := a.runOneTool(ctx, provider.ToolCallBlock{
ID: "T1",
Name: "echo",
Arguments: json.RawMessage(`{"command":"ls"}`),
}, func(AgentEvent) {})
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
if string(rec.lastArgs) != string(newArgs) {
t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
}
}
// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
// malformed JSON as modifiedArgs leaves the original args intact
// (safety: a buggy interceptor can't corrupt the call).
func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
rec := &recordingTool{}
reg := Registry{"echo": rec}
a := NewAgent(nil, "test", "", reg)
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
return true, "", json.RawMessage(`{not json`)
}
ctx := context.Background()
orig := json.RawMessage(`{"command":"ls"}`)
a.runOneTool(ctx, provider.ToolCallBlock{
ID: "T1",
Name: "echo",
Arguments: orig,
}, func(AgentEvent) {})
if string(rec.lastArgs) != string(orig) {
t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
}
}
// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
// the interceptor returns an error ToolResult with the reason text.
func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
rec := &recordingTool{}
reg := Registry{"echo": rec}
a := NewAgent(nil, "test", "", reg)
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
return false, "nope", nil
}
ctx := context.Background()
res := a.runOneTool(ctx, provider.ToolCallBlock{
ID: "T1",
Name: "echo",
Arguments: json.RawMessage(`{"command":"ls"}`),
}, func(AgentEvent) {})
if !res.IsError {
t.Fatal("want error result, got success")
}
if len(res.Content) == 0 {
t.Fatal("no content")
}
tb, ok := res.Content[0].(provider.TextBlock)
if !ok || tb.Text != "nope" {
t.Errorf("want reason 'nope', got %v", res.Content[0])
}
if rec.lastArgs != nil {
t.Error("tool ran despite block")
}
}

View file

@ -91,13 +91,27 @@ type SubscribeFromExt struct {
// EventInterceptResponseFromExt is the extension's reply to an
// EventInterceptFromHost. block=true refuses the underlying action;
// reason is shown to the model as the tool error message. Both
// fields default to (false, "") meaning "allow".
// reason is shown to the model (or the user) as the refusal text.
// All fields default to "allow, pass through unmodified".
//
// Optional rewrite fields, their meaning depends on the event:
//
// - ModifiedArgs: for event="tool_call", replaces the args the
// tool will see. Must be a JSON object literal or the rewrite is
// dropped and a warning logged.
// - ReplaceText: for event="assistant_message", replaces the user-
// visible text. The model's original text stays in the transcript
// (so the model can reference what it "said"); only the rendered
// output to the user is swapped.
//
// When block=true, rewrite fields are ignored.
type EventInterceptResponseFromExt struct {
Type string `json:"type"` // "event_intercept_response"
ID string `json:"id"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
Type string `json:"type"` // "event_intercept_response"
ID string `json:"id"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
ReplaceText string `json:"replace_text,omitempty"`
}
// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
@ -215,20 +229,34 @@ type EventFromHost struct {
}
// EventInterceptFromHost is sent when zot wants to give the
// extension a chance to block / annotate a lifecycle event before
// it happens. Same payload shape as EventFromHost. Reply with
// EventInterceptResponseFromExt within the host's intercept timeout
// (default 5s); missing the deadline is treated as "allow".
// extension a chance to block, modify, or annotate a lifecycle
// event before it happens. Reply with EventInterceptResponseFromExt
// within the host's intercept timeout (default 5s); missing the
// deadline is treated as "allow".
//
// Only Event="tool_call" is sent in this version.
// Supported events and their effect on block=true:
//
// - tool_call: cancel the tool; model sees reason as error.
// Can also modify args via ModifiedArgs.
// - turn_start: cancel the turn before the model call.
// Reason is shown as a chat status line.
// - assistant_message: suppress the message. Can also rewrite
// the user-visible text via ReplaceText.
type EventInterceptFromHost struct {
Type string `json:"type"` // "event_intercept"
ID string `json:"id"`
Event string `json:"event"`
// tool_call payload
ToolID string `json:"tool_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs json.RawMessage `json:"tool_args,omitempty"`
// turn_start payload
Step int `json:"step,omitempty"`
// assistant_message payload
Text string `json:"text,omitempty"`
}
// ShutdownFromHost asks the extension to clean up and exit. Zot

View file

@ -0,0 +1,58 @@
package tui
import (
"strings"
"testing"
"github.com/patriceckhart/zot/internal/provider"
)
// TestStatusBarAlwaysTwoLines verifies the status bar always emits
// two lines when a cwd is present, regardless of terminal width, and
// that the cwd is indented with the 2-space pad so it lines up under
// the "(provider)" column on line 1.
func TestStatusBarAlwaysTwoLines(t *testing.T) {
// Wide terminal that would previously combine into one line.
lines := StatusBar(StatusBarParams{
Theme: Dark,
Provider: "anthropic",
Model: "claude-opus-4-7",
CWD: "/tmp/x",
Usage: provider.Usage{
InputTokens: 476_000,
OutputTokens: 3_400,
CostUSD: 1.242,
},
Subscription: true,
ContextUsed: 55_000,
ContextMax: 1_000_000,
Cols: 500, // very wide
})
if len(lines) != 2 {
t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
}
if !strings.Contains(lines[0], "claude-opus-4-7") {
t.Errorf("line 1 should contain model, got %q", lines[0])
}
// Line 2 must start with 2-space indent.
if !strings.HasPrefix(lines[1], " ") {
t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
}
if !strings.Contains(lines[1], "/tmp/x") {
t.Errorf("line 2 should contain cwd, got %q", lines[1])
}
}
// TestStatusBarNoCWD verifies an empty cwd stays single-line.
func TestStatusBarNoCWD(t *testing.T) {
lines := StatusBar(StatusBarParams{
Theme: Dark,
Provider: "openai",
Model: "gpt-5.4",
CWD: "",
Cols: 200,
})
if len(lines) != 1 {
t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)
}
}

View file

@ -771,15 +771,6 @@ func truncateLines(s string, n int) string {
return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
}
// StatusBar builds the single-line status shown above the editor.
//
// Layout:
//
// [busyPrefix] provider-model tokens-cost ctrl+c hint cwd
// ↑ right-aligned
//
// cols is the terminal width; when > 0 the cwd is placed flush-right
// with spaces. busyPrefix, if non-empty, is injected at the far left.
// StatusBarParams groups the many bits of state the status bar needs.
// Grew from a flat argument list once we started matching pi's format.
type StatusBarParams struct {
@ -811,23 +802,21 @@ type StatusBarParams struct {
Cols int // terminal width; drives right-alignment of cwd
}
// StatusBar builds the status shown above the editor. Returns one or
// two lines depending on whether the (provider, model, stats, cwd)
// payload fits the terminal width.
// StatusBar builds the status shown above the editor. Always returns
// two lines when a cwd is provided: the stats on the first line, the
// cwd on its own line below, indented to match the stats column. This
// keeps the status bar stable across terminal resizes (the cwd never
// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
// makes a long cwd safe at any width.
//
// Layout:
//
// <busyPrefix> (provider) model stats cwd <- one line if it fits
// <busyPrefix> (provider) model stats <- line 1
// cwd <- line 2 (2-space indent)
//
// or, when it doesn't:
//
// <busyPrefix> (provider) model stats <- line 1
// cwd <- line 2
//
// The right-side gap is a fixed three spaces, not a stretched-to-fill
// fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone
// entirely — the slash-command popup and the queued/sliding-in chips
// already cover the discoverability of those keybindings.
// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
// The slash-command popup and the queued/sliding-in chips already
// cover the discoverability of those keybindings.
func StatusBar(p StatusBarParams) []string {
th := p.Theme
@ -903,14 +892,10 @@ func StatusBar(p StatusBarParams) []string {
return []string{primary}
}
cwdRendered := th.FG256(th.Muted, cwd)
combined := primary + pad + cwdRendered
// Wrap to a second line when the combined width would overflow.
if p.Cols > 0 && visibleWidth(combined) > p.Cols {
return []string{primary, cwdRendered}
}
return []string{combined}
// Second line: indent with the same 2-space pad so the cwd lines
// up under the "(provider)" column on line 1.
cwdRendered := pad + th.FG256(th.Muted, cwd)
return []string{primary, cwdRendered}
}
// piContextUsage renders the "N%/ctxMax" fragment, returning the

View file

@ -83,8 +83,65 @@ type Event struct {
// InterceptHandler decides whether a tool call may proceed. Return
// (allow=true) to permit, (allow=false, reason) to refuse. The
// reason is shown to the model as the tool's error text.
//
// This is the original 2-value handler. For richer control (arg
// rewrites, turn_start / assistant_message interception, etc.)
// register via InterceptToolCallX, InterceptTurnStart, or
// InterceptAssistantMessage.
type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)
// ToolCallDecision is the richer reply an InterceptToolCallHandler
// can return. Zero value means "allow, pass through".
type ToolCallDecision struct {
// Block refuses the call. The model sees Reason as the tool's
// error text and typically proposes a different action.
Block bool
// Reason is the refusal text shown to the model on Block, or a
// logged note when passing through (optional).
Reason string
// ModifiedArgs, when non-nil and Block is false, replaces the
// JSON args the tool actually runs with. Lets guards redact /
// augment / patch the model's request without rewriting the
// transcript. Must encode to a JSON object.
ModifiedArgs json.RawMessage
}
// ToolCallHandler is the richer form of an interceptor. Use this
// when you want to rewrite args instead of only blocking.
type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
// TurnStartDecision controls whether the next model call runs. Zero
// value means "allow".
type TurnStartDecision struct {
Block bool
Reason string
}
// TurnStartHandler is called before every turn's model call. Return
// Block=true with Reason to abort the turn (shown to the user as a
// status line). Useful for rate-limiting and business-hour gates.
type TurnStartHandler func(step int) TurnStartDecision
// AssistantMessageDecision controls the final assistant-text
// rendering. Zero value means "allow, show as-is".
type AssistantMessageDecision struct {
// Block suppresses the message from the user entirely. The
// transcript still records the model's original output so the
// model sees what it said on the next turn.
Block bool
// Reason is logged when blocking (optional).
Reason string
// ReplaceText, when non-empty and Block is false, is what the
// user sees. The model's original text still lives in the
// transcript.
ReplaceText string
}
// AssistantMessageHandler is called after the model's final text is
// assembled but before it's shown. Use it to scrub secrets, expand
// templates, enforce tone, or suppress responses entirely.
type AssistantMessageHandler func(text string) AssistantMessageDecision
// ToolResult is the extension's reply to a tool invocation. Build
// one with TextResult, ImageResult, or directly when you need to
// combine multiple blocks.
@ -178,8 +235,12 @@ type Extension struct {
toolDefs []toolDef // ordered so register frames arrive in registration order
eventHandlers map[string]EventHandler
eventNames []string // declared subscription order
interceptTool InterceptHandler
interceptOn bool
interceptTool InterceptHandler
interceptToolRich ToolCallHandler
interceptOn bool
interceptTurn TurnStartHandler
interceptAssistant AssistantMessageHandler
// Caps reported in the hello frame.
caps []string
@ -285,6 +346,9 @@ func (e *Extension) On(name string, fn EventHandler) {
// Use this to build permission gates: refuse `bash` calls containing
// `rm -rf`, ask the user for confirmation on dangerous patterns,
// audit-log every call, etc.
//
// For the richer form (arg rewrites, structured decisions) use
// InterceptToolCallX.
func (e *Extension) InterceptToolCall(fn InterceptHandler) {
e.mu.Lock()
e.interceptTool = fn
@ -292,6 +356,35 @@ func (e *Extension) InterceptToolCall(fn InterceptHandler) {
e.mu.Unlock()
}
// InterceptToolCallX is the richer variant. Return a ToolCallDecision
// to block, allow, or rewrite args mid-flight. If both
// InterceptToolCall and InterceptToolCallX are set, the X form wins.
func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
e.mu.Lock()
e.interceptToolRich = fn
e.interceptOn = true
e.mu.Unlock()
}
// InterceptTurnStart registers a guard that runs before every turn's
// model call. Return Block=true with Reason to abort the turn.
// Useful for deny-by-default gates and usage quotas.
func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
e.mu.Lock()
e.interceptTurn = fn
e.mu.Unlock()
}
// InterceptAssistantMessage registers a guard that runs after the
// model's final text is assembled but before it's shown. Return
// Block=true to suppress entirely, or ReplaceText to rewrite what
// the user sees. The model's original output stays in the transcript.
func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
e.mu.Lock()
e.interceptAssistant = fn
e.mu.Unlock()
}
// Notify pushes an info-level status note into zot's chat without
// requiring a slash command from the user.
func (e *Extension) Notify(level, message string) {
@ -320,7 +413,9 @@ func (e *Extension) Run() error {
descs := append([]descTuple(nil), e.descriptions...)
toolDefs := append([]toolDef(nil), e.toolDefs...)
eventNames := append([]string(nil), e.eventNames...)
interceptOn := e.interceptOn
interceptTool := e.interceptOn
interceptTurn := e.interceptTurn != nil
interceptAsst := e.interceptAssistant != nil
e.mu.Unlock()
for _, d := range descs {
_ = e.send(extproto.RegisterCommandFromExt{
@ -337,12 +432,22 @@ func (e *Extension) Run() error {
Schema: td.schema,
})
}
if len(eventNames) > 0 || interceptOn {
sub := extproto.SubscribeFromExt{Type: "subscribe", Events: eventNames}
if interceptOn {
sub.Intercept = []string{"tool_call"}
}
_ = e.send(sub)
var intercepts []string
if interceptTool {
intercepts = append(intercepts, "tool_call")
}
if interceptTurn {
intercepts = append(intercepts, "turn_start")
}
if interceptAsst {
intercepts = append(intercepts, "assistant_message")
}
if len(eventNames) > 0 || len(intercepts) > 0 {
_ = e.send(extproto.SubscribeFromExt{
Type: "subscribe",
Events: eventNames,
Intercept: intercepts,
})
}
// Sentinel: tells the host that all initial registrations have
// been flushed and the agent registry can be built. Never block
@ -442,30 +547,7 @@ func (e *Extension) Run() error {
if err := json.Unmarshal(line, &ei); err != nil {
continue
}
e.mu.Lock()
fn := e.interceptTool
e.mu.Unlock()
if fn == nil || ei.Event != "tool_call" {
_ = e.send(extproto.EventInterceptResponseFromExt{
Type: "event_intercept_response", ID: ei.ID,
})
continue
}
go func(id, name string, args json.RawMessage) {
defer func() {
if r := recover(); r != nil {
_ = e.send(extproto.EventInterceptResponseFromExt{
Type: "event_intercept_response", ID: id,
Block: true, Reason: fmt.Sprintf("intercept panic: %v", r),
})
}
}()
allow, reason := fn(name, args)
_ = e.send(extproto.EventInterceptResponseFromExt{
Type: "event_intercept_response", ID: id,
Block: !allow, Reason: reason,
})
}(ei.ID, ei.ToolName, ei.ToolArgs)
go e.dispatchIntercept(ei)
case "shutdown":
_ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
return nil
@ -523,3 +605,68 @@ func (e *Extension) send(v any) error {
_, err = e.out.Write(b)
return err
}
// dispatchIntercept runs the per-event handler (tool_call / turn_start
// / assistant_message) on its own goroutine, catches panics, and
// always emits exactly one event_intercept_response. Called from the
// Run loop.
func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
defer func() {
if r := recover(); r != nil {
_ = e.send(extproto.EventInterceptResponseFromExt{
Type: "event_intercept_response",
ID: ei.ID,
Block: true,
Reason: fmt.Sprintf("intercept panic: %v", r),
})
}
}()
resp := extproto.EventInterceptResponseFromExt{
Type: "event_intercept_response",
ID: ei.ID,
}
switch ei.Event {
case "tool_call":
e.mu.Lock()
rich := e.interceptToolRich
plain := e.interceptTool
e.mu.Unlock()
if rich != nil {
d := rich(ei.ToolName, ei.ToolArgs)
resp.Block = d.Block
resp.Reason = d.Reason
if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
resp.ModifiedArgs = d.ModifiedArgs
}
} else if plain != nil {
allow, reason := plain(ei.ToolName, ei.ToolArgs)
resp.Block = !allow
resp.Reason = reason
}
case "turn_start":
e.mu.Lock()
fn := e.interceptTurn
e.mu.Unlock()
if fn != nil {
d := fn(ei.Step)
resp.Block = d.Block
resp.Reason = d.Reason
}
case "assistant_message":
e.mu.Lock()
fn := e.interceptAssistant
e.mu.Unlock()
if fn != nil {
d := fn(ei.Text)
resp.Block = d.Block
resp.Reason = d.Reason
if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
resp.ReplaceText = d.ReplaceText
}
}
}
_ = e.send(resp)
}