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.")
NoteVerified by the test suite

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 — warns

What 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 silent

The 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

See also