"Which guardrail framework should I use?" is the wrong question for a kill switch. The best interceptor in the world still intercepts something that exists.

Quick disclosure before the code: I work on KarmaBox, where agent permissions are enforced exactly the way this article recommends, so I'm arguing my own book. Three things keep this checkable: the pattern below is ~40 lines of dependency-free Python you can run and attack yourself; every news fact carries its outlet and date; and the last section lists what this pattern does not solve — including the part my employer hasn't solved either.

Why this is worth your evening this particular week: on November 3, Google announced that schools disabling its AI features lose Google Search entirely, because — per its education VP — "Search and AI are deeply integrated" (TechCrunch, November 3, 2026). Set aside the politics. As engineers, read it as an architecture confession: they built the off switch into a layer where flipping it takes down the building. This article is about not building that.

The definition, before the code

A real kill switch removes a capability. It does not hide it, intercept it, or instruct the model to decline it — it makes the capability unreachable, for every caller, through every path. If "off" is implemented anywhere the capability can still be invoked, what you have is a gate: a thing that says no while the wiring behind it stays live.

The distinction matters because gates and absences fail in opposite directions. A gate that crashes, gets misconfigured, or gets argued past fails open — the capability runs. An absence cannot fail open, because there is nothing on the other side of the switch to run. At 2 a.m., that's the whole difference.

Two architectures for "off"

Most agent stacks implement "off" as middleware: the tool stays registered, and a policy layer intercepts calls.

# Architecture A: middleware gate agent → tool_call("send_email") → policy middleware → [allow? deny?] → send_email() # Architecture B: registry absence agent → tools_for(role) → send_email was never in the list 

In Architecture A, send_email exists in the agent's tool list. The model can see it, plan around it, and attempt it; a middleware decides at call time. In Architecture B, the tool surface is assembled per role from a registry, and a capability that is off was simply never assembled in. The model cannot call what it cannot see, and neither can a prompt injection riding in on a document.

two ways to build "off"
The failure-mode comparison, side by side:

One row deserves emphasis because this week made it concrete: the audit story. Google's announcement also claims schools can toggle each service individually — but two months from now, the only way a district can verify what was actually off is whatever the console shows. A switch you can't independently audit is a promise with a UI. Registries make the audit cheap: the assembled list is the ground truth.

The pattern in ~40 lines

Dependency-free, three permission tiers: direct (runs), approval (runs after a human approves), absent (not assembled at all).

# registry.py — capability registry with three tiers from dataclasses import dataclass from typing import Callable TIERS = ("direct", "approval", "absent") @dataclass(frozen=True) class Capability: name: str fn: Callable tier: str # one of TIERS class Registry: def __init__(self): self._caps: dict[str, Capability] = {} def register(self, name, fn, tier): if tier not in TIERS: raise ValueError(f"unknown tier: {tier}") self._caps[name] = Capability(name, fn, tier) def tools_for(self, role_policy: dict[str, str]) -> dict[str, Capability]: """Assemble the tool surface for one role. A capability whose tier is 'absent' is NOT in the result. There is no gate to bypass — it simply isn't mounted.""" surface = {} for name, tier in role_policy.items(): cap = self._caps.get(name) if cap is None or tier == "absent": continue # never mounted surface[name] = Capability(name, cap.fn, tier) return surface def audit(self, role_policy: dict[str, str]) -> list[tuple[str, str]]: """Export the effective capability list — this is the audit artifact.""" mounted = self.tools_for(role_policy) return sorted( (name, mounted[name].tier if name in mounted else "absent") for name in role_policy ) def dispatch(surface, name, approver=None, **kwargs): cap = surface.get(name) if cap is None: raise LookupError(f"{name}: not a capability of this role") # fail closed if cap.tier == "approval": if approver is None or not approver(name, kwargs): raise PermissionError(f"{name}: approval required, none given") return cap.fn(**kwargs) 

Notes on the load-bearing lines. tools_for is where "off" happens: an absent capability is skipped at assembly, so the model's tool list — the thing you serialize into the API call — never contains it. dispatch fails closed on anything outside the surface: an unknown or absent tool raises, it doesn't fall through to a default handler. And audit exists because the Google story's quiet lesson is that consoles and reality drift; your auditor gets the assembled list, not a screenshot of settings.

Three tests, and what it means when each fails

def test_absent_is_unreachable(registry): surface = registry.tools_for({"send_email": "absent"}) assert "send_email" not in surface try: dispatch(surface, "send_email", to="x@y.z") assert False, "dispatch must raise" except LookupError: pass 

If this fails: your "off" is a gate, not an absence — somewhere a fallback path still resolves the tool. This is the test that separates the two architectures; everything else is decoration.

failure direction is the whole design

def test_approval_blocks_without_human(registry): surface = registry.tools_for({"post_public": "approval"}) try: dispatch(surface, "post_public", body="hi") # no approver passed assert False, "must require approval" except PermissionError: pass 

If this fails: your approval tier silently degraded into the direct tier — the exact drift the middleware column of the table predicts. Expect to find a "temporary" default-approver someone added during an incident.

def test_audit_matches_surface(registry, role_policy): audit = dict(registry.audit(role_policy)) surface = registry.tools_for(role_policy) for name, tier in audit.items(): assert (name in surface) == (tier != "absent") 

If this fails: your audit artifact lies. That's worse than having no audit, because people will trust it. Fix this before fixing anything it reports.

The audit endpoint

Expose registry.audit(role_policy) as an internal endpoint or a CLI command and snapshot it on every deploy. Go one step further and make the snapshot a CI artifact: a job that diffs the assembled surface against the last release and fails the build on any unreviewed widening — a capability moving from absent to anything, or a new tool appearing in a role. Narrowing can auto-pass; widening needs a human's name on it. Now "what could the agent do on March 12" is a git log question, not an archaeology project. When a vendor tells you a capability "can be disabled," this is the artifact to ask them for — the effective capability list, not the settings screenshot. (For schools asking Google that question right now: per the November 3 reporting, the answer for Search-AI is that no such list will ever show it off while Search is on. The wiring is the policy.)

One more direction worth knowing about, published the same day: Anthropic released FlashProve, open-source research toward cryptographic proof of which model produced an output — fingerprints published per model, no weights revealed, verifiable locally (TechCrunch, November 3, 2026). Registry audit tells you which capabilities were mounted; provable inference points at the next layer — proving which model sat behind them. Research status, no deployments; but that's the end state audits are crawling toward.

Migrating an existing stack: the strangler path

Nobody gets to rewrite their agent platform this sprint, so here is the incremental path we'd actually recommend — the classic strangler pattern, applied to tool surfaces. Four stages, each shippable on its own.

Stage one: inventory the gates. Grep for wherever tool calls get intercepted today — middleware, decorators, the if-statement graveyard inside your dispatcher. Each interception point is a capability that believes it has an off switch. List them with their current default behavior on error: you will find at least one that fails open, and finding it is the first win of the migration.

Stage two: wrap, don't replace. Introduce the registry in front of the existing gates. The registry assembles the tool surface; the old gates stay exactly where they are, now guarding capabilities that were already filtered upstream. Nothing can break, because nothing was removed — you've added a second, stricter filter.

Stage three: dual-run with a mismatch alarm. For a few weeks, log every case where the old gate blocked something the registry had mounted, or — the interesting direction — where the registry excluded something the gate would have allowed. Each mismatch is either a policy bug or an undocumented dependency. We'd treat the second kind exactly like the accounting-filters discovery in this week's sysadmin threads: evidence of load-bearing coupling nobody wrote down.

Stage four: flip per role, then delete. Move one role at a time to registry-only enforcement, lowest-risk role first. Delete the gates last, not first — dead code that can't be reached through the registry is annoying; a gate deleted before the registry covered it is an incident.

Two objections that always come up, answered honestly. Latency: effectively none — assembly happens once at session start, not per call; tools_for is a dictionary filter. Operational cost: yes, changing a capability now requires a deploy instead of a config flip. That is not a drawback to minimize; it is the entire point. A capability change that produces a diff, a reviewer, and a rollback path is the audit trail — the config flip that skips all three is how the middleware column of the failure table happens.

What this pattern does not solve

Honesty section, including against my employer. The registry pattern does not solve judgment: deciding which capability belongs in which tier is a human call, and a wrong call with perfect enforcement is still wrong. It does not solve data already sent: a capability you unmount today did whatever it did last quarter. It does not solve indirect effects: a direct-tier tool can still be misused within its scope (a search tool can exfiltrate by querying; the registry won't see it). And it does not give you cryptographic verification — my audit endpoint is logs you have to trust; the FlashProve direction is the eventual fix, and nobody ships it yet, including the product I work on.

The question worth taking back to your own stack: for each tool your agent can currently see — if the policy service died at 2 a.m., which way does it fail?

Sources

  1. TechCrunch (Aisha Malik), Google will let schools disable its AI tools, but not without sacrificing Search, November 3, 2026

  2. TechCrunch (Russell Brandom), Anthropic is working on a system to prove AI models' identity, November 3, 2026 FlashProve is research with published code, no deployment.

  3. Code in this article: illustrative, dependency-free, written for this post — run it, break it, tell me where.