skip to content

Why can an audit hook added with sys.addaudithook never be removed from a running interpreter?

level: middleimportance: nice to knowfreq 12%

answer

  1. There is no matching remove function
  2. The audited code must not be able to disarm it
  3. Adding a hook is itself an audited event
  4. A RuntimeError from that event blocks the new hook
  5. Installation must be idempotent and early

basics

~20 s

CPython exposes no hook-removal API at all: once sys.addaudithook accepts a callable, it stays for the life of that interpreter. The irrevocability is deliberate -- an audit facility the audited code could switch off would be worth nothing.

solid answer

~50 s

There is no `sys.removeaudithook`, and that omission is the design, not an oversight. The code being audited is by assumption capable of running arbitrary Python, so a removal API would be the first thing an attacker or a careless library called. The consequences are practical: installation must be idempotent, because a module imported twice under different names would otherwise add two hooks; the hook object and everything its closure captures live for the whole interpreter; and a hook installed inside a test leaks into every later test in that process, so audit-hook tests belong in a fresh interpreter. There is also a sealing trick -- `sys.addaudithook` itself raises the `"sys.addaudithook"` event, and an existing hook that raises `RuntimeError` from it blocks the new hook. On 3.14 that refusal is *suppressed*: the caller sees no exception and no hook.

code

python · 14 lines
python
import sys

print(hasattr(sys, "removeaudithook"))

seen = []

def sealing_hook(event, args):
    if event == "sys.addaudithook":
        raise RuntimeError("hook chain is sealed")

sys.addaudithook(sealing_hook)
sys.addaudithook(lambda event, args: seen.append(event))
sys.audit("etl.probe")
print("second hook saw:", seen)

go deeper

for a junior

Remember the shape of the rule: adding an audit hook is one-way. There is no removal call, so decide once, at startup, whether the process gets one.

for a middle

Explain why the API is deliberately one-way -- the code being audited could otherwise disarm its own monitoring -- and name the practical consequences: idempotent installation, a closure that lives forever, and tests that must run in a child process.

for a senior

Show the operational consequence: a bad hook cannot be pulled at runtime, so the body stays tiny, its dependencies are read indirectly rather than captured, and the policy it applies is data rather than baked-in code.

for a principal

Own the tradeoff between the guarantee's security value and the loss of any runtime rollback, and set the rule for where hooks may be installed in your base images so that no team can add one from a library import.

## The guarantee, stated plainly `sys.addaudithook` has no inverse. `hasattr(sys, "removeaudithook")` is `False` on 3.14, as it has been since PEP 578 shipped in 3.8. Once a hook is accepted it is called for every subsequent audit event until the interpreter that owns it shuts down. The reasoning is the whole point of the facility. An audit hook exists to report what the code running in this interpreter does. That code is, by assumption, capable of executing arbitrary Python -- that is exactly why you wanted to watch it. If a removal function existed, disabling the monitoring would be a one-line prelude to whatever came next, and every report produced by the mechanism would be worth exactly as much as the attacker's cooperation. A monitor whose subject can switch it off is not a monitor. ## The sealing trick, and its 3.14 sharp edge Adding a hook is itself an audited operation: `sys.addaudithook` raises the event `"sys.addaudithook"` with no arguments before installing. Existing hooks therefore get to see, and object to, every later installation. If any of them raises an exception derived from `RuntimeError`, the new hook is not added. The sharp edge is what happens to that exception: it is *suppressed*. The caller of `sys.addaudithook` receives no error, no warning and no return value indicating failure -- the call simply completes and the hook silently does not exist. A first hook can therefore seal the chain so that nothing installed later ever sees an event, and code that assumed its own hook was live will keep running as if it were. If your hook must be present, verify it after installing rather than trusting that the call succeeded. ## What irrevocability costs you **Installation must be idempotent.** A module can be imported more than once in a process -- as `__main__` and again under its package name, or through two different sys.path entries -- and each import would add another copy of the hook, doubling the per-event cost. Guard installation with a module-level flag, or install from one place you control (a startup module executed by the `site` machinery) rather than from library import side effects. **The hook is immortal, and so is everything it holds.** A hook that closes over a configuration object, a queue, an open file or a logging handler pins all of it for the process lifetime, and none of it can be closed and replaced. A hook that writes to a file handle keeps failing once that handle is closed, and there is no way to unsubscribe it -- so a hook body should reach its dependencies through indirection (a module-level variable it re-reads) rather than capturing them. **Tests leak.** Anything installed by a test stays installed for the rest of the process, slowing and potentially breaking every later test in the same worker. Audit-hook behaviour is verified by running the scenario in a child process and asserting on its output, not by installing hooks in the test process. **There is no rollback.** A hook that turns out to be too slow, too noisy or buggy cannot be pulled at runtime; the only remedy is restarting the process with different configuration. That is the operational cost of the guarantee, and it is why the hook body should be small and the *policy* it applies should be data it reads, not code baked into the closure. ## The off switch that is not one The usual workaround is a module-level `enabled` flag that the hook checks first and returns on. That is a legitimate performance and operations lever -- it reduces the hook to a global read plus a return -- and it is fine when the hook exists for telemetry. It is not a security control. Any code that can set the flag has disarmed the hook, which is precisely the situation the missing removal API was meant to prevent, reintroduced in Python. If the hook is enforcing something, its decision must not be reachable by the code it is watching. ## Scope: which interpreter The hook belongs to the interpreter that installed it. On 3.14 the stdlib exposes multiple interpreters through `concurrent.interpreters`, and a hook added with `sys.addaudithook` in the main interpreter does not see events raised inside an interpreter created there -- the new interpreter starts with an empty hook list. Only a hook installed by an embedder through the C API before initialisation is applied to every interpreter in the process. "Never removable" is therefore a promise about one interpreter's lifetime, not about the process.

  • How would you write tests for a module that installs an audit hook?
    Run the scenario in a child interpreter and assert on what it printed or wrote, because a hook installed in the test process cannot be taken back and will affect every later test in that worker. Keep the hook body itself a thin adapter around a pure policy function, and unit-test that function directly with synthetic event names and argument tuples.
  • Is a module-level enabled flag inside the hook an acceptable off switch?
    As a telemetry and performance lever, yes -- it reduces the hook to a global read and a return. As a security control, no: any code that can flip the flag has disarmed the hook, which recreates exactly the hole the missing removal API was designed to close. If the hook enforces policy, its decision must not be reachable from the code it audits.
  • Does the guarantee cover the whole process?
    It covers the interpreter that installed the hook. A hook added from Python is per-interpreter, so an interpreter created through the 3.14 stdlib's multiple-interpreters support starts with no hooks and sees none of yours. Only a hook installed by an embedder through the C API before initialisation applies to every interpreter in the process.

saying these in an interview costs you the question

  • Says you can just call a remove function to uninstall
  • Suggests deleting the hook from a list on sys
  • Treats an enabled flag as a security control
  • Assumes a failed installation raises for the caller
  • Installs a hook from library import side effects
  • Installs hooks inside unit tests in the same process

context