Durable cross-session state
Warn the first time a sensitive file is edited in this project, then stay silent in every later session.
You want a hook that learns and remembers. The first time the agent edits a secrets file, env file, or migration, you warn; after that you stay quiet, even in tomorrow’s session. Session state forgets at the end of each session, so it can’t carry the “already warned” fact forward. DurableState can. A scope="project" model keeps one file per repo, and Deque[256] caps the remembered set so it never grows without bound.
"""Warn the first time a sensitive file is edited in this project — once, across every session."""
from __future__ import annotations
from captain_hook import (
Allow,
BaseHookEvent,
Deque,
DurableState,
Event,
HookResult,
Input,
Tool,
Warn,
on,
)
SENSITIVE = ("migrations/", "secrets", ".env")
class WarnedPaths(DurableState, scope="project"):
paths: Deque[256]
def is_sensitive(path: str) -> bool:
return any(marker in path for marker in SENSITIVE)
@on(
Event.PreToolUse,
only_if=[Tool("Edit|Write")],
tests={
Input(tool="Edit", file=".env", content="API_KEY=x\n"): Warn(pattern="sensitive"),
Input(tool="Edit", file="app/users.py", content="x = 1\n"): Allow(),
},
)
def warn_once_per_project(evt: BaseHookEvent) -> HookResult | None:
if not (fp := evt.file) or not is_sensitive(path := str(fp.path)):
return None
with WarnedPaths.mutate(evt) as state:
if path in state.paths:
return None
state.paths.append(path)
return evt.warn(f"Editing a sensitive file (`{path}`). Double-check before committing secrets.")The inline tests assert the first-fire warn and a benign allow. The “stays silent in every later session” behavior needs durable state seeded from a prior session, which single-event inline tests can’t set up. It’s covered by tests/test_durable.py.
The handler mutates under a file lock. WarnedPaths.mutate(evt) loads the remembered paths, checks membership, and appends the new one, all atomic, so two concurrent sessions can’t both warn for the same path or lose each other’s writes.
What it catches
Input(tool="Edit", file=".env", content="API_KEY=x\n") # first edit of a sensitive file — warnsWhat it allows
Input(tool="Edit", file="app/users.py", content="x = 1\n") # not sensitive — never warns
# a second edit of .env in any later session — already remembered, stays silentThe warn / allow split mirrors the hook inline tests, so it stays true as the hook evolves.
Run it yourself
uvx capt-hook --hooks docs/examples test