airlock kill-switch — HMAC-signed freeze with quorum reset¶
Landed in v0.5.9. Implementation src/agent_airlock/kill_switch/; CLI
src/agent_airlock/cli/kill_switch.py.
Status: operational as of v0.8.86¶
Through v0.8.85 this froze nothing. The HMAC signing, the envelope, the listener state
machine and the M-of-N quorum were all correct and all inert, because
KillSwitchListener was never constructed inside the library — the only references
outside kill_switch/ were the CLI and the CLI dispatcher table. Triggering the switch
halted no tool call anywhere.
Three things changed:
| before v0.8.85 | now | |
|---|---|---|
@Airlock consults the switch |
never | yes, ahead of every other gate |
| Cross-process transport | NATS/Redis/S3 were stubs delegating to an in-process queue | RedisStreamTransport under the [redis] extra |
CLI trigger / reset |
published into a transport discarded at exit, printed OK |
require a real transport, or fail |
It is opt-in and it is two steps. A process must both construct a listener and register it; constructing one alone still changes nothing:
from agent_airlock.kill_switch import HMACBroadcastSigner, KillSwitchListener, registry
from agent_airlock.kill_switch.transports import RedisStreamTransport
registry.install(KillSwitchListener(
signers=(
HMACBroadcastSigner(keyid="ops-a", key=KEY_A),
HMACBroadcastSigner(keyid="ops-b", key=KEY_B),
HMACBroadcastSigner(keyid="ops-c", key=KEY_C),
),
transport=RedisStreamTransport.from_url("redis://localhost:6379/0"),
))
Give every listener every operator's key. A listener holding fewer signers than the reset threshold (2 by default) can be frozen but never reset.
After install, a blocked call returns block_reason: "kill_switch" — a distinct reason
from policy_violation, because "an operator halted the fleet" and "this call is
forbidden" need different responses.
What it does¶
An operator signs a trigger broadcast with a shared key. Every process running a
KillSwitchListener that holds a matching key sees state == TRIGGERED on its next
poll() and reports is_frozen(). One signed broadcast is enough to freeze. Returning to
service needs a quorum of distinct configured signers, 2-of-3 by default, so one
compromised key cannot unilaterally re-enable agents.
How a reset vote is counted¶
Since 0.10.24:
- A vote belongs to the key that verified it. The listener counts a
resetunder the configured signer whose MAC matched, not under thekeyidthe envelope claims. A reset whose claimedkeyiddiffers from its key's is refused as a vote and logged (kill_switch_keyid_mismatch). Atriggerwith the same mismatch still freezes: the MAC proves a configured key signed it, and refusing a freeze over a label would fail open. - No sender's clock sets the window. A signed
ts_epochis ordered no later than the listener's own clock plusmax_clock_skew_seconds(300 s by default); a stamp further ahead is ordered as that bound and logged (kill_switch_stamp_clamped). A trigger stamped years ahead still freezes at once, and its votes count once they are signed after the bound, not after a date a fast clock picked. - A trigger always freezes. A distinct trigger is never dropped for its stamp, however old: refusing a freeze would fail open. A newer trigger during a freeze restarts the vote.
- A vote counts only against the freeze it answers. It counts only while the listener is frozen, and only when its stamp is later than the freeze's threshold: the highest stamp the listener had applied when the freeze began (triggers and counted votes alike). The threshold never moves backward, so votes captured from an earlier incident cannot release a later freeze, even one triggered from a slow clock.
- A replay is recognised by its signature. The listener remembers the signatures of the
last 4096 broadcasts it verified, and ignores one it has already seen
(
kill_switch_replay_ignored): a captured trigger cannot re-freeze a fleet that was reset, and a replay of the active trigger cannot wipe the votes already cast. A fresh listener replaying a stream's history sees each broadcast once, in order, and rebuilds the same state. - One unreadable frame never stops the batch. A frame that is not JSON, has no HMAC-SHA256
hex signature or carries a non-finite
ts_epochis logged and skipped, and the broadcasts queued behind it are still applied. - Signers whose votes cannot be told apart are refused at construction. Two signers that
repeat a keyid, or share a key, raise
QuorumError.
Until 0.10.24 the listener counted the claimed keyid, so one key signing two resets under
two keyids met the default quorum alone, and ts_epoch was never compared, so the two
resets that ended one incident, replayed off the stream, released the next freeze. The
second needed no key, only write access to the transport. A malformed frame raised out of
poll() and dropped a real trigger queued behind it, which needed no key either.
The wire envelope is compact JSON with sorted keys, signed over its canonical form:
{"action":"trigger","keyid":"ops-a","reason":"prod incident 412",
"signature":"<hex hmac-sha256>","ts_epoch":1757000000.0,"version":1}
version is BROADCAST_VERSION, bumped only on incompatible payload changes. Signing keys
must be at least 32 bytes; shorter keys raise InvalidBroadcastSignature at construction.
Runnable example¶
The whole feature in one script. Output is from an actual run:
from agent_airlock import Airlock
from agent_airlock.kill_switch import (
HMACBroadcastSigner, InMemoryTransport, KillSwitchBroadcast,
KillSwitchListener, registry,
)
@Airlock()
def deploy(target: str) -> str:
return f"deployed to {target}"
bus = InMemoryTransport() # swap for RedisStreamTransport to cross processes
ops_a = HMACBroadcastSigner(keyid="ops-a", key=b"a" * 32)
ops_b = HMACBroadcastSigner(keyid="ops-b", key=b"b" * 32)
listener = KillSwitchListener(signers=(ops_a, ops_b), transport=bus, poll_interval_seconds=0)
registry.install(listener) # <- without this, @Airlock never asks
print(listener.state.value, "|", deploy(target="prod"))
# armed | deployed to prod
KillSwitchBroadcast(signer=ops_a, transport=bus).trigger(reason="incident 412")
print(deploy(target="prod")["block_reason"])
# kill_switch -- the decorated function is now refused
# One key is not enough to come back: the default quorum is 2-of-3.
KillSwitchBroadcast(signer=ops_a, transport=bus).reset(reason="all clear")
print(deploy(target="prod")["block_reason"], listener.quorum_progress())
# kill_switch (1, 2)
KillSwitchBroadcast(signer=ops_b, transport=bus).reset(reason="all clear")
print(deploy(target="prod"))
# deployed to prod
# A broadcast signed by a key the listener does not hold is dropped, not applied.
intruder = HMACBroadcastSigner(keyid="ops-a", key=b"z" * 32) # right keyid, wrong key
KillSwitchBroadcast(signer=intruder, transport=bus).trigger(reason="forged")
print(deploy(target="prod"))
# deployed to prod
Two things worth reading twice. The forged message claims a keyid the listener trusts and is still rejected, because acceptance is decided by the MAC and not by the claimed identity. And the same key voting to reset five times, or under five different keyids, is still one vote: the quorum counts distinct verifying keys, so a single compromised key cannot re-enable a fleet.
Commands¶
export AIRLOCK_KILL_SWITCH_KEY="<>= 32 bytes, hex or raw>"
export AIRLOCK_KILL_SWITCH_REDIS_URL="redis://localhost:6379/0" # or --redis-url
airlock kill-switch status --keyid ops-a
airlock kill-switch trigger "prod incident 412" --keyid ops-a
airlock kill-switch reset "all clear" --keyid ops-a --quorum 2-of-3
airlock kill-switch arm ops-a # validates key material, reads fleet state
--key-env and --stream-key override the key variable and the Redis stream on any
subcommand. status exits 1 when the fleet is frozen, so it composes in a shell check.
Without a transport, the publishing commands fail rather than reporting success:
no broadcast transport configured — this command would reach no process.
Pass --redis-url redis://host:6379/0, or set AIRLOCK_KILL_SWITCH_REDIS_URL.
Requires the redis extra: pip install "agent-airlock[redis]"
That refusal is the point. Until v0.8.86 these commands printed
OK: trigger queued (transport=InMemoryTransport) and reached nothing.
What this does not cover¶
- A freeze takes effect within one poll interval, not instantly. The listener polls at
most once per
poll_interval_seconds(5 s by default) so a transport read does not land on every tool call. Set it to0to poll on every call. A process that makes no tool calls never polls, and is therefore never frozen — the switch stops calls, not processes. - Transport failures fail open by default. If a poll raises, the previous state is kept
and the error is logged, because a kill switch that halts every agent when its own
transport hiccups is an outage generator. This is the opposite of the library's usual
deny-by-default posture, so it is a deliberate exception: pass
strict=Truetoregistry.installto freeze on transport errors instead. --quorumdoes not travel in the broadcast. The threshold is listener configuration (reset_quorum_threshold/reset_quorum_total). The CLI now parses and validates the flag —--quorum 9-of-3is rejected rather than echoed — but uses it only to tell you how many more votes are needed.- NATS and S3 transports are still stubs that delegate to an in-process queue, as is
the deprecated
RedisTransportStub. OnlyRedisStreamTransportcrosses a process boundary. Anything else needs the two-methodBroadcastTransportprotocol implemented against your own bus. - Votes are ordered by the signers' clocks, within a bound. A reset signed on a clock
behind the freeze's threshold is refused, which fails closed: re-send it from a
synchronised clock. A stamp more than
max_clock_skew_secondsahead of the listener is ordered at that bound, so a trigger or vote from a fast clock delays the next reset by at most that allowance (wait it out, then re-send the votes). A listener whose own clock runs behind its signers by more than the allowance orders every stamp at its bound; a trigger and the votes for it then need to arrive in different polls. Broadcasts from one process are always ordered: the broadcaster never repeats a stamp. - Replay memory is per process and bounded. A listener started with
replay_history=False(cursor$) never saw the broadcasts before it started, so a replayed old trigger freezes it. That fails closed; reset it with a fresh quorum. Past 4096 signatures the oldest are forgotten, and a replayed vote that old is still refused by the threshold rule above. - A listener holding fewer signers than the threshold cannot be reset. It still freezes.
This is documented rather than refused because the CLI's
statusandarmbuild single-key, read-only listeners. Until 0.10.24 one key under two--keyidlabels could reset such a listener; that was the forgery above, not a feature.