Admit signed webhook events to an agent pipeline without trusting the payload.
Webhook Guard is for operators who let billing, CRM, deployment, support, or automation systems trigger autonomous workers. It verifies HMAC-SHA256 signatures, rejects stale events, records accepted event IDs in SQLite, and quarantines malformed, forged, expired, or replayed envelopes before downstream automation sees them.
Honest scope
The admission decision is deterministic and works offline. The optional local
LLM CLI sees only verified metadata (source, event_id, event_type, and
content_type) and may suggest a queue. It never sees the webhook body and
cannot change an accept/reject verdict.
| Capability | Deterministic core | Optional LLM |
|---|---|---|
| HMAC verification | Yes | No |
| Timestamp window | Yes | No |
| Replay protection | Yes | No |
| Quarantine and audit report | Yes | No |
| Conservative queue selection | Yes | No |
| Metadata-only routing suggestion | Fallback | Yes |
| HTTP server or reverse proxy | No | No |
| Provider-specific signature formats | Customize | No |
This pack consumes local JSON envelope files. Your existing HTTP gateway must
write those files after preserving the exact request body. See CUSTOMIZE.md.
Who it is for
- LLM and automation operators accepting external event triggers.
- Small teams needing a fail-closed ingress gate without a hosted dependency.
- Integrators testing webhook authenticity and replay behavior offline.
- Security reviewers who need a compact, inspectable admission ledger.
Quick start
Install on Debian or Ubuntu:
sudo bash install.sh
Or run in place without root:
export WEBHOOK_GUARD_SECRET='replace-with-at-least-16-random-bytes'
python3 agent.py --help
python3 -m unittest -q test_agent
Create and sign a local example:
NOW="$(date +%s)"
export NOW
python3 -c 'import json,os; print(json.dumps({
"source":"billing",
"event_id":"evt-demo-001",
"event_type":"payment.succeeded",
"timestamp":int(os.environ["NOW"]),
"body":"{\"amount\":1200,\"currency\":\"USD\"}"
}))' > /tmp/webhook.json
SIG="$(python3 agent.py sign --input /tmp/webhook.json)"
export SIG
python3 -c 'import json,os; p="/tmp/webhook.json"; d=json.load(open(p)); d["signature"]=os.environ["SIG"]; open(p,"w").write(json.dumps(d))'
python3 agent.py verify --input /tmp/webhook.json --no-record
For a durable replay ledger, omit --no-record. A second verification of the
same (source, event_id) returns exit code 2 with verdict replay.
Event envelope
{
"source": "billing",
"event_id": "evt-123",
"event_type": "payment.succeeded",
"content_type": "application/json",
"timestamp": 1784912400,
"body": "{\"amount\":1200,\"currency\":\"USD\"}",
"signature": "sha256=0123456789abcdef..."
}
The signed bytes are:
ASCII(timestamp) + "." + exact UTF-8 body bytes
Use body_base64 instead of body for binary data. Exactly one body field is
required.
Architecture
provider webhook
|
v
existing HTTP gateway
|
| exact body + signature + timestamp + event ID
v
local inbox/*.json
|
v
+-----------------------+
| Webhook Guard |
| 1. bound input size |
| 2. verify HMAC |
| 3. check clock window |
| 4. reserve event ID |
+----------+------------+
|
+----+----+
| |
accepted/ quarantine/
| |
safe route audit only
suggestion
|
downstream agent
Commands
python3 agent.py status
python3 agent.py sign --input envelope.json
python3 agent.py verify --input envelope.json --db webhook_guard.sqlite3
python3 agent.py run --once --inbox inbox --accepted accepted --quarantine quarantine
python3 agent.py run --llm-routing
verify exits 0 only for an accepted event, 2 for a rejected event, and 1 for
local configuration or input errors.
Security boundaries
- Secrets are read from an environment variable or file, never from CLI flags.
- Comparisons use
hmac.compare_digest. - The replay check and insert occur under
BEGIN IMMEDIATE. - Invalid events never create replay-ledger entries.
- The body is never sent to an LLM.
- Nothing in this pack opens a network socket or executes webhook content.
- Quarantine is an audit destination, not a retry queue.
Start with CHECKLIST.md, then adapt the gateway and source mappings in
CUSTOMIZE.md.
Use a local model (Ollama)
The optional LLM step can also run on your own machine through Ollama, so no text leaves your server. No account and no API key.
ollama pull <model> # any chat model you like
export PACK_LLM=ollama # prefer Ollama even if a coding-agent CLI is installed
export PACK_OLLAMA_MODEL=<model> # optional: default is the first chat model the server lists
python3 -c "import llm_adapter; print(llm_adapter.detect())" # prints: ollama
PACK_OLLAMA_HOSTpoints to another server (defaulthttp://127.0.0.1:11434);OLLAMA_HOSTis also read whenPACK_LLM=ollama.PACK_OLLAMA_NUM_CTXsets the context size if your reports or prompts are long.- Without
PACK_LLM, a local Ollama server is used only when no Claude Code, Kimi Code or Codex CLI is found. - If the server is down or the model is missing, the pack keeps working in its deterministic mode.
Learning memory (local and private)
Every LLM call this pack makes is journaled in ./.pack_memory/ next to the code, so the pack can improve for you. Nothing is ever sent anywhere.
- What is kept: the task name, the masked input, the masked answer, status and latency. Keys, tokens, e-mails, phone numbers, IP addresses, card and IBAN numbers and personal paths are masked before writing; the system prompt is never stored.
- Teach it:
python3 pack_memory.py feedback last okconfirms a result,python3 pack_memory.py feedback last bad --fix "the right answer"corrects it. Confirmed and corrected runs are reused as validated examples when a similar input comes in. Unrated runs are never injected into a prompt. - Find similar cases:
python3 pack_memory.py similar "some text"andpython3 pack_memory.py clusters(groups of near-identical failures) work in plain Python, no model needed. Optional meaning-based matching uses an embedding model you already run locally:python3 pack_memory.py enable-semantic(Ollama on this machine only). - Share, only if you want:
python3 pack_memory.py exportwrites an anonymised summary (counts, statuses, size buckets, no text) for you to review. - Control:
PACK_MEMORY=0turns it off,PACK_MEMORY_STORE_TEXT=0keeps fingerprints only,python3 pack_memory.py purge --yeswipes it.
