zot/examples/extensions/guard
patriceckhart 83ae236571 feat(extensions): phase 3 — event subscriptions + tool-call interception
Two new capabilities, both ride on the existing subprocess
protocol with a couple of new frame types.

Event subscriptions (one-way notifications):

  ext  -> host: subscribe {events: [...], intercept: [...]}
  host -> ext:  event {event, ...payload}

  Recognised events: session_start, turn_start, turn_end,
  tool_call, assistant_message. Subscribers get fire-and-forget
  notifications on each. Useful for telemetry, audit logs, custom
  state widgets that follow live agent activity.

Tool-call interception (round-trip, can refuse):

  host -> ext:  event_intercept {id, event:"tool_call", tool_name, tool_args}
  ext  -> host: event_intercept_response {id, block?, reason?}

  When at least one extension subscribed to "tool_call" intercept,
  zot asks each one in turn before running every tool call. First
  blocker wins; reason becomes the tool-result error text the model
  sees. Per-extension 5s timeout treats unresponsive interceptors
  as "allow" so a wedged extension never stalls the agent.

Wire format additions (internal/extproto):
  ext -> host: SubscribeFromExt, EventInterceptResponseFromExt
  host -> ext: EventFromHost, EventInterceptFromHost

Manager (internal/agent/extensions):
  - per-extension eventSubs / interceptSubs sets, populated by the
    subscribe frame
  - EmitEvent fans out to every subscribed extension on its own
    goroutine (won't block the agent on slow stdin writes)
  - InterceptToolCall walks subscribers serially, returning the
    first refusal; 5s timeout per subscriber (allow on timeout)
  - readLoop handles event_intercept_response correlations the
    same way it handles command/tool responses

Core (internal/core/agent.go):
  - Agent.BeforeToolExecute hook called from runOneTool right
    before tool.Execute. Returning (allowed=false, reason)
    short-circuits with an IsError tool result containing reason.
  - Agent.OnEvent observer fires for every emitted AgentEvent;
    composed transparently with the per-Prompt sink via wrapSink
    so neither the existing TUI nor the rpc loop need changes.

Wiring (internal/agent/cli.go, rpc.go):
  - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and
    OnEvent -> fanoutAgentEvent for every freshly-built agent
    (initial, login rebuild, model swap)
  - fanoutAgentEvent translates core AgentEvent kinds into
    extproto.EventFromHost. Internal-only events (text_delta,
    tool_progress) are dropped to keep the per-extension stream
    sane.
  - session_start emitted once after extensions come up

SDK (pkg/zotext):
  - On(name, EventHandler) registers per-event observers
  - InterceptToolCall(InterceptHandler) registers a single
    intercept callback
  - Run() now also sends a subscribe frame before the ready
    sentinel, with the union of subscribed events + intercept
  - Frame loop handles "event" and "event_intercept" frames,
    runs the handlers (intercepts on a goroutine to avoid
    head-of-line blocking)
  - Capabilities advertised: commands + tools + events

Example (examples/extensions/guard):
  - subscribes to session_start / turn_start / tool_call / turn_end
    and writes one-line audit entries
  - intercepts every bash call; refuses commands matching
    rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777
  - end-to-end verified live: agent -> bash("rm -rf /tmp/foo")
    -> guard refuses -> model sees the refusal text and surfaces
    it in its reply ("the guard blocked it, as expected — the
    pattern \brm\s+-rf\b matched")

Docs/extensions.md updated with all five new frame types and the
guard example.
2026-04-19 14:57:03 +02:00
..
extension.json feat(extensions): phase 3 — event subscriptions + tool-call interception 2026-04-19 14:57:03 +02:00
main.go feat(extensions): phase 3 — event subscriptions + tool-call interception 2026-04-19 14:57:03 +02:00
README.md feat(extensions): phase 3 — event subscriptions + tool-call interception 2026-04-19 14:57:03 +02:00

guard — example zot extension (Go, phase 3)

Demonstrates the event subscription and tool-call interception half of the extension protocol (phase 3).

What it does:

  • Subscribes to session_start, turn_start, tool_call, turn_end and appends a line to /tmp/zot-guard-audit.log for each.
  • Intercepts every bash tool call. If the command matches a danger regex (rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777), the call is refused. The model sees the refusal as the tool error and (typically) proposes something safer or asks for confirmation.

Build

cd examples/extensions/guard
go build -o guard .

Install

zot ext install .

Try it

In zot, ask:

Run rm -rf /tmp/foo

The model's bash call is intercepted and refused; the model explains the refusal in its reply. No file is touched.

Run ls /tmp

Allowed; the audit log records the call.

Tail the audit log:

tail -f /tmp/zot-guard-audit.log

Extending the danger list

Edit dangerPatterns in main.go. Each entry is a Go regexp; the match is case-insensitive. Rebuild and reinstall.

See also

  • examples/extensions/hello — slash command (phase 1)
  • examples/extensions/clock — slash command in plain Node (phase 1)
  • examples/extensions/weather — LLM-callable tool (phase 2)
  • docs/extensions.md — full protocol reference