Module 49 · 55 min

Two Hooks

You can inject the wiki's rules at session start under a byte cap, deny a write into `raw/` with JSON rather than an exit code, and say what the deny does not protect you from.

Surface
hooks/hooks.json · wikictl.py hook entrypoints
Ships to your plugin
hooks/hooks.json
Claude Code
v2.1.251
Docs checked
2026-09-02

Everything so far runs when somebody asks for it. That is a problem, and here is the shape of it: the rules in .llm-wiki/CLAUDE.md are a file nobody opens, and the append-only policy is a sentence in a skill that may or may not be loaded at the moment it matters.

Hooks close both gaps, because a hook is code Claude Code runs at a lifecycle moment whether the model cooperates or not. Module 09 covers the mechanism in general. We need two events here for two different jobs, and the interesting part is that those jobs want opposite behaviour out of the same machinery.

llm-wiki/
└─ hooks/
hooks/hooks.json

Two events, one script, two subcommands. Both time out at 5 seconds.

{
"description": "Inject bounded wiki context and guard append-only raw sources.",
"hooks": {
  "SessionStart": [
    {
      "matcher": "startup|resume|clear|compact|fork",
      "hooks": [
        {
          "type": "command",
          "command": "python3",
          "args": [
            "${CLAUDE_PLUGIN_ROOT}/scripts/wikictl.py",
            "hook",
            "session-start"
          ],
          "timeout": 5
        }
      ]
    }
  ],
  "PreToolUse": [
    {
      "matcher": "Write|Edit|Bash|PowerShell",
      "hooks": [
        {
          "type": "command",
          "command": "python3",
          "args": [
            "${CLAUDE_PLUGIN_ROOT}/scripts/wikictl.py",
            "hook",
            "protect-raw"
          ],
          "timeout": 5
        }
      ]
    }
  ]
}
}
The whole hook configuration. Read the two matchers first: they decide when each hook is even considered.

Hook one: the rules arrive before the first question

SessionStart runs at the start of a session and can add text to the model’s context. Ours emits two things in order: the instance rules from .llm-wiki/CLAUDE.md, then wiki/index.md.

Feed it the JSON a real session would, and it answers:

{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "## LLM Wiki instance rules\n\n# LLM Wiki instance\n..."
  }
}

Exit 0. The text under additionalContext becomes part of what the model has read.

Two details in that output decide whether this helps or hurts, and both are the kind of thing you find out the hard way.

The cap. capped_text (line 851) takes the parts in order and appends each one until it reaches session_context_bytes from the config, 8192 by default. Once it is full it stops, mid-part if necessary. Truncation happens at a byte boundary and the result is decoded with errors="ignore", so a cut through the middle of a multi-byte character drops the broken fragment rather than raising an exception.

The cap is why the index has to stay an index. Every session pays for this text, and the rules come first in the parts list, so a wiki/index.md that has grown into a content dump gets truncated while the rules survive. That ordering is worth copying: put the text you cannot afford to lose first, and let the growable thing be the one that gets cut.

The failure mode. hook_session_start (line 868) wraps its entire body in except (WikiCtlError, OSError, ValueError): return 0, with a comment in the source that names the reason: “Session startup context is best-effort and must not break Claude Code.”

So: no config file, exit 0 and inject nothing. Unreadable index, exit 0 and inject nothing. This hook cannot stop a session from starting, which is the correct choice for a hook whose job is to be helpful. It also means a broken wiki looks exactly like no wiki, and you find out by noticing the rules stopped appearing.

Hook two: the deny travels in JSON

Now for the other job. PreToolUse runs before a tool call and can stop it. Send it a Write aimed at a registered raw file:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "llm-wiki raw/ is append-only. Do not Write/Edit an existing raw source. Add revisions as new files, then ingest and explicitly supersede the old source if appropriate."
  }
}

Exit 0. Not exit 2.

That is the part to slow down on, because it contradicts what you learned earlier. Module 09 establishes that exit 2 is the blocking exit code for hooks. This hook blocks with exit 0 and a JSON decision instead, because permissionDecision is a richer answer than a code: it carries a reason string, and the model reads that string. A bare exit 2 stops the tool call and tells the model nothing about what to do next, so it tries a variation. The reason above tells it the rule and the correct alternative in one sentence, which is why the next thing it does is usually write a new file rather than retry the same one.

Aim the same hook at wiki/managed-hsm.md and it prints nothing at all and exits 0. Silence is the “no decision” answer: the hook has no opinion, and the normal permission flow proceeds as though the hook were not there.

SituationOutputExitEffect
Write to a file under raw/deny JSON0Tool call refused, reason shown to the model
Write to a file under wiki/nothing0No decision, normal permission flow runs
Bash containing a mutation aimed at raw/deny JSON0Tool call refused
No wiki root above the working directorynothing0No decision

What the shell filter catches, and what it does not

Next up is the messy half. Write and Edit carry a file path, so checking them is a path comparison and there is nothing clever about it. Bash and PowerShell carry a string, and deciding whether a string will modify a file means reading the string and guessing.

Two regular expressions do that. MUTATING_SHELL_RE (line 34) matches rm, mv, cp, install, touch, truncate, tee, sed -i, perl -i, the git subcommands checkout, restore, clean and reset, and the PowerShell cmdlets Set-Content, Add-Content, Out-File, Remove-Item, Move-Item, Copy-Item, New-Item and Clear-Content. REDIRECT_RE (line 44) matches the redirection operators >, >>, 1>, 2> and &>.

The README states the limit of this in its own words: “The shell hook is intentionally not presented as a security sandbox. Complex shell indirection can evade static command inspection. The real integrity layers are the manifest hash comparison and Git history.”

Take that seriously rather than as modesty. A command built from a variable, a path assembled at runtime, a script invoked by name: none of those are visible to a regular expression reading the command line. The hook is a guardrail on the direct route, and the direct route is what an honest mistake looks like.

The layer that actually holds is the one from module 46. inventory hashes the bytes on disk and compares them to the manifest, so it detects the change no matter how the change arrived. The hook makes the mistake harder to make. The hash makes it impossible to hide.

That is both hooks. One that gives and fails open, one that refuses and explains itself.

Ships to your plugin

Create hooks/hooks.json with both entries, both resolving the script through ${CLAUDE_PLUGIN_ROOT} and both carrying a timeout. Add a hook subcommand to wikictl.py that reads the event JSON from stdin and dispatches to session-start or protect-raw. Make session-start catch every exception and return 0. Make protect-raw emit the deny JSON with exit 0, and print nothing for anything outside raw/. Test both without a session by piping fixtures in: echo ’{“hook_event_name”:“PreToolUse”,“tool_name”:“Write”,“cwd”:”…”,“tool_input”:{“file_path”:“raw/x.md”}}’ | python3 scripts/wikictl.py hook protect-raw should print the deny and exit 0, and the same fixture pointed at wiki/x.md should print nothing and exit 0. A hook you have only tested inside a session is a hook you cannot debug when it goes quiet.

The mistake most people make first

Leaving compact out of the SessionStart matcher, because the obvious sources are startup and resume and those are the ones you test. Here is what you see when it bites, and the answer is nothing. The session starts, the rules are injected, and for an hour everything is correct. Then the conversation gets long enough to compact, meaning Claude Code summarises the history to reclaim context, and the injected text is not part of the summary. The rules are gone. No message says so. What you notice, if you notice at all, is that the model has started treating raw/ as an ordinary folder halfway through a session where it was behaving perfectly. This one is hard to catch because the failure is separated from its cause by however long compaction took to happen, and because the session that misbehaves is the same session that was fine.

Does this travel?

The mechanism does not. Hook event names, the hookSpecificOutput envelope, permissionDecision, and ${CLAUDE_PLUGIN_ROOT} are Claude Code’s, and none of them exist elsewhere under those names. What travels is the pair of shapes. A context injector that fails open, so a broken helper cannot stop work, and a guard that fails closed with a reason, so a refusal teaches instead of frustrating. Any harness with a lifecycle to hang code on needs both, and the choice of which behaviour goes with which job is the transferable part.

Check yourself

  1. protect-raw denies a tool call with exit 0 rather than exit 2. Name the thing the JSON carries that the exit code cannot, and say what the model does differently because of it.
  2. hook_session_start returns 0 when it cannot read the config. Describe what a user sees when that happens, and why that is harder to notice than an error.
  3. The shell filter is a regular expression over the command string. Give one command that modifies a file under raw/ and would not match it, and name the check that catches it anyway.