Alerts & events
v0.13 After stopping an out-of-contract call, the gate’s next job is telling a human. cloakpipe mcp-proxy raises a gate event for every decision that needs attention and delivers it three ways: HMAC-signed webhooks (raw JSON, or a Slack or Discord message), OpenTelemetry spans, and an append-only JSONL file. This is new in v0.13.0; v0.12.0 has the ledger and stderr only.
Delivery is off the decision path. Events go on a bounded queue that a background thread drains, so a slow or dead endpoint never delays a decision or changes it. Everything runs on your machines: the gate sends events only to the endpoints you give it.
Event kinds
| kind | when |
|---|---|
decision.deny |
a call was refused in enforce mode, including fail_closed, an expired receipt (approval_expired) and v0.14 a call an approver denied through a broker (approval_denied) |
approval.requested |
a call needs an approval receipt and has none |
approval.mismatch |
a receipt was presented that does not cover this exact call (other arguments, release or tool, or a bad signature); v0.14 also a receipt from an approval broker that does not verify and was not written (no ledger entry) |
shadow.finding |
shadow mode forwarded a call that enforce mode would have refused |
side_effect.replayed |
a retry of a completed side effect was answered from the operations store |
side_effect.unknown |
a forwarded side effect never got its reply: reconcile it |
tool.drift |
a tool’s definition does not match its pin, or changed during the session (see pinning) |
anchor.failed |
anchoring a ledger epoch failed (reason is rfc3161 or rekor) |
An allowed call raises no event, but it does get an OpenTelemetry span.
What is in an event
Each event is one JSON object with "schema": "cloakpipe.event/v1". The injected send_email from the quick start raises this one. The attacker’s address appears nowhere in it:
{
"schema": "cloakpipe.event/v1",
"kind": "decision.deny",
"fingerprint": "sha256:…",
"ts": "2026-10-28T09:14:03Z",
"release": "support-agent",
"release_digest": "sha256:…",
"policy_version": "sha256:…",
"tool": "send_email",
"server": null,
"reason": "scope_mismatch",
"path": "/to",
"decision": "deny",
"mode": "enforce",
"operation_id": null,
"ledger_seq": 2,
"entry_hash": "sha256:…"
}
policy_version is the Cedar policy digest, path the contract path of an argument refusal, server the upstream server (for tool.drift), and ledger_seq and entry_hash name the decision ledger entry the event follows. An event never carries an argument value, a tool result or a credential.
Fingerprint. fingerprint is sha256: plus the hex SHA-256 of kind|reason|tool|path|policy_version, where an absent member counts as an empty string. For tool.drift, the tool part is <server>/<tool>. The same failure gets the same fingerprint on every run and every gate, so an alerting pipeline can group repeats into one issue; a policy change starts a new one.
The flags
The flags need --manifest, since without the gate there are no events. They are checked at start-up: an unreadable secret, a URL that is not HTTP(S) or a log path that cannot be written stops the gate with exit code 2.
cloakpipe mcp-proxy \
--manifest cloakpipe.manifest.yaml \
--upstream "python3 server.py" \
--events-log ~/.cloakpipe/events.jsonl \
--webhook https://alerts.example.com/cloakpipe \
--webhook-secret-file ~/.cloakpipe/webhook.secret \
--otel-endpoint http://localhost:4318
| Flag | Meaning |
|---|---|
--events-log PATH |
append each event to PATH as one JSON line; an existing file is never truncated |
--webhook URL |
POST each event to URL; repeatable |
--webhook-secret-file FILE |
sign webhook requests with this secret (one trailing newline is ignored) |
--webhook-format generic|slack|discord |
the payload for every --webhook (default generic, the event JSON) |
--otel-endpoint URL |
an OTLP/HTTP collector; spans go to URL/v1/traces |
--otel |
use OTEL_EXPORTER_OTLP_ENDPOINT as the endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT on its own exports nothing. Pass --otel to use it, so a variable set for some other program never starts sending the gate’s decisions anywhere.
Webhooks
Each webhook request is a POST with Content-Type: application/json and these headers:
CloakPipe-Event: <kind>CloakPipe-Fingerprint: <fingerprint>CloakPipe-Signature: t=<unix seconds>,v1=<hex>, when a secret is set. Without one the request is unsigned, and the gate warns at start-up.
Delivery. Each --webhook has its own queue of 1024 events and its own thread. A full queue drops new events and counts them, with a warning on stderr. Each event gets three tries, 5 s timeout each, with a backoff of 0.5 s and then 1 s; a connection error, a timeout, 429 or 5xx is retried, and each try is signed again with a fresh t. When the gate stops, it waits up to 5 s for queued events. The queue is in memory, so events queued when the process dies are lost; the signed ledger still has every decision.
Verifying the signature
v1 = hex( HMAC-SHA256( secret, "<t>" + "." + <raw request body> ) )
A receiver should:
- Split the header on
,and readtand everyv1. - Compute
v1over the raw body bytes. Re-serialized JSON will not match. - Compare it in constant time with each
v1in the header. Any match is enough, which lets you rotate secrets. - Reject a
tmore than about five minutes from its own clock, so an old request cannot be replayed.
cloakpipe events verify-signature does exactly this, so you can test your receiver’s code against it. Here openssl computes a signature and the gate checks it:
printf 'whsec-example-secret' > webhook.secret
printf '{"kind":"decision.deny","reason":"scope_mismatch"}' > body.json
t=$(date +%s)
v1=$(printf '%s.%s' "$t" "$(cat body.json)" | openssl dgst -sha256 -hmac "$(cat webhook.secret)" | sed 's/^.* //')
cloakpipe events verify-signature --secret-file webhook.secret --header "t=$t,v1=$v1" --body-file body.json
# signature ok
A changed body or another secret exits 1 with signature does not match the body. So does a t more than --tolerance seconds away (default 300; --tolerance 0 turns the age check off).
Slack
-
In Slack, create an app with Incoming Webhooks turned on, add a webhook to the channel you want, and copy its URL.
-
Send a sample event to check the wiring:
cloakpipe events test --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack -
Run the gate with the same webhook:
cloakpipe mcp-proxy --manifest cloakpipe.manifest.yaml --upstream "python3 server.py" \ --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack
Each event becomes a Block Kit message. Its header names the kind and the tool, such as “Tool call denied: send_email”. Fields follow for the reason, the tool, the contract path, the release and the mode, plus the ledger seq and the operation when there is one. A context line shows the kind and the fingerprint. Slack ignores the signature header, so a secret is optional here.
Discord
Create a webhook in the channel’s Integrations settings, then run with --webhook "$DISCORD_WEBHOOK_URL" --webhook-format discord. Each event becomes one embed with the same fields, coloured red for a refusal, amber for something that needs a human and blue for a replay. The fingerprint goes in the embed’s footer.
cloakpipe events test sends a sample decision.deny in any of the three formats, and exits 0 on a 2xx answer and 1 otherwise.
OpenTelemetry
--otel-endpoint http://collector:4318 exports one span named cloakpipe.decision for every tool-call decision, allows included, as OTLP/HTTP JSON, in batches from a background thread. The resource’s service.name is cloakpipe-gate.
| Attribute | Value |
|---|---|
cloakpipe.decision |
allow, deny or require_approval |
cloakpipe.reason |
the reason code (ok for an allow) |
cloakpipe.tool, cloakpipe.path |
the tool, and the contract path of an argument refusal |
cloakpipe.release, cloakpipe.manifest_digest, cloakpipe.policy_digest |
the release, its manifest digest and its Cedar policy digest |
cloakpipe.mode, cloakpipe.forwarded |
enforce or shadow, and whether the call reached the tool |
cloakpipe.operation_id |
the side-effect operation, if any |
cloakpipe.ledger.seq, cloakpipe.ledger.entry_hash |
the decision’s ledger entry |
cloakpipe.event, cloakpipe.fingerprint |
the event the decision raised, if any |
A refused call is an error span. Spans carry no argument values, and a span’s duration is the time the gate took to decide, ledger write included.
In Docker
The Docker image takes the same settings as variables: CLOAKPIPE_WEBHOOK_URL, CLOAKPIPE_WEBHOOK_SECRET_FILE, CLOAKPIPE_WEBHOOK_FORMAT, CLOAKPIPE_OTEL_ENDPOINT and CLOAKPIPE_EVENTS_LOG.
Not built
- Roadmap A different secret and format for each webhook. Today one
--webhook-secret-fileand one--webhook-formatapply to every--webhook. - Roadmap Delivery that survives a restart.
- Roadmap A hosted plane on top of these events: grouping repeats into issues, routing by tool, approval cards in Slack, escalation and digests. It is not built (the open-source gate can already file approvals with a broker you run: see Approvals), and nothing is sent to a hosted service; if you want it, tell us on the waitlist.