Extensions

An extension changes how the agent behaves without forking it. Extensions are separate processes speaking line-delimited JSON on stdin and stdout, so they can be written in any language and need no build step in Abhed.

The one rule

An extension may veto, never permit.

It can block a call, force it to an approval prompt, rewrite arguments before a tool runs, rewrite a result before the model reads it, drop messages before they go upstream, supply a compaction summary, and add tools. It cannot turn a denied action into an allowed one.

The reason is structural. The policy engine evaluates hooks first so they can veto — which means a hook returning "allow" would short-circuit the deny rules beneath it. That is acceptable for code an operator compiled in; it is not acceptable for a file an operator dropped into a directory. A permission gate an extension can remove is not a guarantee.

Protocol

One JSON object per line each way. Abhed writes a request; the extension writes exactly one reply.

// Abhed → extension
{"event":"tool_call","session_id":"s-1","tool":"bash","args":{"command":"rm -rf /tmp/x"}}

// extension → Abhed
{"block":true,"reason":"rm -rf is not permitted here"}

Events

EventWhenThe reply may
tool_callbefore a tool runsblock, ask, rewrite args
tool_resultbefore the model sees outputrewrite content, set is_error
contextbefore each model callkeep a subset of messages, by index
before_agent_startonce per runappend to system
before_compactbefore history is summarizedcancel, or supply the summary
list_toolsonce at startupdeclare tools this extension provides
invoke_toolthe model called onereturn its result
session_start, session_endrun boundariesnothing; setup and teardown

Declaring no events subscribes to all of them. An empty reply {} means no opinion.

Configuration

"extensions": [
  { "name": "guard", "command": "bash", "args": ["/opt/abhed/guard.sh"],
    "events": ["tool_call"], "timeout_ms": 5000 }
]

Worked examples

Block reads of anything matching a pattern:

#!/bin/bash
while IFS= read -r line; do
  tool=$(jq -r '.tool // ""' <<<"$line")
  path=$(jq -r '.args.path // ""' <<<"$line")
  if [[ "$tool" == "read" && "$path" == *secret* ]]; then
    echo '{"block":true,"reason":"secrets are off limits"}'
  else
    echo '{}'
  fi
done

Redact credentials from every tool result:

#!/bin/bash
while IFS= read -r line; do
  if [[ $(jq -r '.event' <<<"$line") == "tool_result" ]]; then
    jq -c '{content: (.content | gsub("AKIA[A-Z0-9]{16}"; "[redacted]"))}' <<<"$line"
  else
    echo '{}'
  fi
done

Keep something the summarizer would drop:

#!/bin/bash
while IFS= read -r line; do
  if [[ $(jq -r '.event' <<<"$line") == "before_compact" ]]; then
    echo '{"summary":"Working on ticket ABC-123. Keep the ticket id."}'
  else
    echo '{}'
  fi
done

Behaviour that is deliberate

They combine toward the stricter answer. Where two disagree, the blocking one wins; where one asks for approval and another is silent, the call is asked. Load order cannot change a verdict, which keeps the audit trail reproducible.

A failing extension is skipped, not fatal. One that crashes, hangs past its timeout, or replies with something unparseable is marked dead and skipped; the agent continues under policy alone. Failing the session would trade a working agent for a broken one and protect nothing, since an extension could only ever have made a decision stricter.

A hung extension is not retried. The read is abandoned but the stream is not, so a later reply would be matched to the wrong request.

The extension sees the whole call, including fields the model wrote itself, such as a bash description. Deliberate — an extension judging a command should see the stated intent alongside it — but it means a naive whole-line match can fire on a description rather than the command. Match the field you mean.

Providing tools

See Tools.