Hooks - ChiefGyk3D/scrum-around-and-find-out GitHub Wiki
Generated from docs/hooks.md at df1dbe1. Edit the repository file, not the wiki.
safo hooks turns the routing rules in agents.yaml into two Claude Code hooks, so they are enforced and not
remembered. The guard is a speed bump and an audit trail, not a security boundary: whoever writes a brief can type the
approval token, and that includes a model that a prompt injection has talked into it.
| Command | Claude Code event | What it does |
|---|---|---|
safo hooks install [--settings PATH] [--mode warn|block] [--command safo] |
none | Merges the two hooks into a settings.json (default ~/.claude/settings.json). Keeps every other hook and setting, is a no-op when run again, keeps a private backup before it replaces the file, and refuses to overwrite a file that changed while it worked (bytes, or the file itself: device, inode, size and modification time are checked again just before the replace, so a rename-over with identical bytes is caught too, with settings changed while installing). --dry-run writes nothing. |
safo hooks probe |
SessionStart | Asks each Ollama endpoint in agents.yaml which models are loaded (two bounded GETs, never a generate), saves the answer and tells the session whether the local LLM is reachable. |
safo hooks guard |
PreToolUse, matcher Agent|Task
|
Checks each subagent brief against three rules (below). |
safo hooks mode warn|block |
none |
warn reports a broken rule and allows the dispatch (the default); block denies it. |
safo hooks status [--session ID --agents PATH] |
none | The mode, the retained counts, the health of the hooks and of the last probe. |
-
An explicit, known model. A brief with no
modelis flagged (no-model). A model counts as explicit only when it is an exact id or model name of an agent inagents.yaml(or a fullclaude-<family>-<version>id of one).inherit, any unrecognised name and a fork with no model resolve to a parent model the hook cannot see, so they are flagged asunknown-modelunless the approval token is in the brief, the same as Opus; inblockmode that is a denial. The log records the model asunknown, never the value that was sent. -
The approval token. A known model that
agents.yamlmarksapproval_requiredneedshooks.approval_tokenin the brief, found however it is cased or wrapped. Put it there only with the maintainer's recorded OK. The token is a speed bump against accidents, not proof of the maintainer's approval: it travels in model-written text, so a prompt-injected model, or any quoted source material, can forge it.blockmode therefore does not prove a human said yes; it only makes forgetting visible. -
A local step or a reason for none, while the last probe is fresh and found the local LLM reachable: a marker from
hooks.local_step_markers, orhooks.na_markerfollowed by a reason.
A hook sits inside someone's session, so every error lets the work through, visibly.
- Malformed, oversized (over 1 MiB), non-object or missing-field input, a standard input that never arrives (5 s), an
unreadable or invalid
agents.yaml, a corrupt mode file, a failed state or log write, an unexpected exception and a command line the hook cannot parse (read from the real command line, sopython -m safo hooks guard --no-such-flagtoo) all exit 0, print the fixed lineSAFO routing guard degraded: enforcement may be incomplete; inspect safo hooks status.as hook JSON, and add an enum-only record to the log. No exception text, input value or prompt is echoed or stored. - What a failure switches off: input or configuration failures switch off every rule; a state failure (missing, invalid,
future-dated or other-session state, or a re-probe that failed) switches off only the local-step rule; a mode failure
falls back to
warn. - A denial that was computed survives a log failure.
- stdout is exactly one JSON line or nothing.
probeandguardnever exit 2 (a host reads that as a denial), even for a bad flag. - The host's own timeout (15 s for the probe, 10 s for the guard in the installed settings) is the last backstop. How a given Claude Code version treats a timed-out or crashed hook is not verified here.
Under $XDG_STATE_HOME/safo/hooks (default ~/.local/state/safo/hooks), mode 0600: log.jsonl (decision, mode, model
identifier or unknown, booleans, rule names, health, diagnostic enum; at most 2048 bytes a record, 4 MiB, one archive)
and per-session probe files named by a hash of the session id and the merged configuration, holding a completion time,
booleans and role flags. No prompt, description, address, endpoint name or loaded-model name is stored. Probe files
untouched for a week are removed when a session starts. The mode lives in ~/.config/safo/mode.
SAFO creates its own state and config directories mode 0700. Before it uses one, the directory must be owned by the
effective user and not group- or world-writable; otherwise the feature degrades visibly instead of trusting it: the
guard allows with the degraded notice and writes no log, lock or state there, the probe reports a state-write
failure, and an untrusted config directory means the mode file is ignored (warn, health degraded). The reason is
that a lock is a pathname's inode: anyone who can write the directory can unlink a held lock and make a second one, or
replace the mode, state and log files.
Run safo hooks install --dry-run --settings /tmp/try.json first. Start in warn, read safo hooks status, then
switch to block. Before recommending block, run the disposable real-session smoke test listed in the plan (Task
T9h, step 5): fixture tests cannot show that a real Claude Code session displays the warning or honours a denial.
Under GitHub Actions the agents file, the local override and the outcomes log are refused when they lie inside
$GITHUB_WORKSPACE, by the path as written or after following links, and every explicit path is refused when
GITHUB_WORKSPACE is unset; only the home defaults remain. Accepted residual: outside Actions, an explicit --agents,
--log or $SAFO_AGENTS naming a file in the current directory is the user's own deliberate choice and is allowed
(the current directory is never searched on its own).
An Ollama endpoint may be loopback or a private LAN address. Link-local, unspecified, multicast and cloud-metadata
addresses (including IPv4-mapped IPv6 and decimal, octal and hex spellings) are refused when agents.yaml is loaded
and again at connect time. A name is resolved once, every address it returns is checked, and the connection goes to the
checked address with the original Host header, so a DNS answer that changes between the check and the connection has
no effect.
- The approval token is forgeable (see rule 2). A channel the model cannot write to, such as a signed or
session-bound approval the maintainer issues outside the conversation, is future work; v0.1.0 has none, and nothing in
blockmode should be read as proof of approval. - Directory checks look at the SAFO directory itself, not the path above it, and the installer's own lock and temp file
sit beside the settings file you name (for example
/tmp), which it does not require to be private. - The installer narrows the replace race but pathname operations cannot close it: an external writer can still land between the final identity check and the rename.