Log coding-agent hook payloads to a file. One binary, invoked directly by the agent's hook system as each event fires — no server to start, no port to coordinate.
Cursor and Claude Code are supported agent providers. Others plug in via agent.Provider.
go build -o hookmon .Point your agent's hook config at the binary (see below), and pass
--log-file so events get persisted:
./hookmon --agent cursor --log-file ./hooks.logEvery invocation reads the hook JSON from stdin, appends it (wrapped in an
envelope naming the agent) to --log-file, and acknowledges. If --log-file
is unset, hookmon still acknowledges but doesn't record anything — set it to
capture events.
Default log file: none (--log-file, HOOKMON_LOG_FILE, or .hookmon.yaml).
By default hookmon only observes — every hook call is acknowledged as allowed. To actually block specific tools at specific hook events, add a shared, checked-in policy file (see examples/policy/.hookmon-policy.yaml):
rules:
- event: PreToolUse
agent: claudecode
tools: ["Bash"]
action: deny
reason: "Direct Bash calls are blocked by hookmon policy."
default-action: allowPoint hookmon at it with --policy-file, HOOKMON_POLICY_FILE, or
policy-file: in .hookmon.yaml (default: .hookmon-policy.yaml in the
current directory). Each rule matches a hook event name (agent-native
casing, e.g. PreToolUse for Claude Code, preToolUse for Cursor),
optionally scoped to one agent, one or more exact tools, one or more
paths, and/or one or more commands; action is allow, deny, or
ask. When several rules match, the strictest wins (deny > ask >
allow).
tools, paths, and commands all AND with the rest of the rule (and with
each other) — a rule needs every filter it specifies to match. paths
scopes a rule to specific files, e.g. to block reads/writes of .env
without blocking Read/Write outright:
- event: PreToolUse
agent: claudecode
tools: ["Read", "Write"]
paths: [".env"]
action: deny
reason: ".env files are blocked by hookmon policy."Path patterns are matched gitignore-style against tool_input.file_path
(currently populated for Claude Code only): a bare pattern like .env or
*.env matches the filename at any depth by comparing whole path
components, never a substring — so foo.envelope.txt is correctly not
caught by .env. A pattern ending in / (e.g. .git/) anchors to a
directory component instead of the filename. A rule with paths set but no
matching file path in the event (e.g. a Bash call, which has no
file_path) simply doesn't match — same fail-open rule as everything else.
commands scopes a rule to Bash calls whose command string contains one of
the given substrings (matched against tool_input.command, Claude Code
only), e.g. to block git push without blocking Bash outright:
- event: PreToolUse
agent: claudecode
tools: ["Bash"]
commands: ["git push"]
action: deny
reason: "git push is blocked by hookmon policy."Matching is a plain substring test, not a glob or regex, so "git push"
also catches "git push --force" and "git push origin main". A rule with
commands set but no command in the event (e.g. a non-Bash tool) simply
doesn't match — same fail-open rule as paths.
Other commands worth considering for a commands rule in an agentic coding
setup: rm -rf (destructive deletes), git reset --hard / git clean -fd
(silently discards uncommitted work), sudo (privilege escalation), and
curl/wget (especially piped into a shell — a remote-code-execution
pattern). A rule's commands list can hold several patterns at once (any
one matching is enough), and action: ask is worth using instead of deny
for a lower-confidence match like bare curl/wget, where the command
itself isn't inherently dangerous:
- event: PreToolUse
agent: claudecode
tools: ["Bash"]
commands: ["rm -rf", "git reset --hard", "sudo"]
action: deny
reason: "This command is blocked by hookmon policy."
- event: PreToolUse
agent: claudecode
tools: ["Bash"]
commands: ["curl", "wget"]
action: ask
reason: "Network downloads via Bash require confirmation."As with git push, substring matching is blunt here — "rm -rf" also
catches harmless cleanup like "rm -rf ./build", and "sudo" also catches
read-only uses like "sudo apt list". See
examples/policy/.hookmon-policy.yaml
for the full versions of these rules.
Substring and glob matching can only enumerate spellings, never state a
condition. commands: ["rm -rf"] blocks harmless rm -rf ./build and misses
rm -r -f ./src, /bin/rm -rf src, and anything through eval. A when:
clause asks what the call would actually do:
judgments:
destroys_work:
type: noul
instructions: >
Would running this irreversibly destroy source code, uncommitted
changes, or data the user could not easily recover?
criteria:
"true": "It loses work with no straightforward undo."
"false": "It only affects regenerable artifacts — build output, caches, dependencies."
rules:
- event: PreToolUse
tools: ["Bash"]
when: { destroys_work: ">= 0.85" }
on-error: ask
action: deny
reason: "This command would irreversibly destroy work."Judgments are answered by TypeSafe's System One model, which returns a typed probability rather than generated text. Code keeps everything deterministic — event, agent and tool matching, rule precedence, thresholds, and the fail-open contract; the model supplies only the semantic judgment about the unstructured command or path.
Each entry under judgments: has a type, instructions, and optional
criteria:
| Type | Answers with | criteria shape |
|---|---|---|
noul |
a probability from 0 to 1 | optional map: {"true": …, "false": …} |
choice |
one of your options | map of option name to description |
score |
a position along ordered levels | ordered list, one description per level |
Write one narrow judgment per question, and make each criterion describe a concrete situation that stands on its own.
when: maps a judgment to a condition. Every entry must hold (AND):
when:
destroys_work: ">= 0.85" # noul probability
blast_radius: ">= 2" # score level
destination: "== shared" # choice option
destination.confidence: ">= 0.6"Operators are >=, >, <=, <, ==, !=. A bare number means >= and a
bare word means ==, so destroys_work: 0.85 works unquoted. A
.confidence suffix reads how concentrated a choice or score answer was;
noul answers have no confidence, because the probability is the signal.
Because when: ANDs with event/agent/tools/paths/commands, adding a
judgment to an existing rule can only ever make it fire less often. That
makes narrowing a blunt rule with a judgment a strictly safe edit: it can
remove false positives, but it can never introduce a block that wasn't
already there. To express OR, write two rules.
hookmon first works out which rules match lexically, then asks only the
judgments those rules reference — all of them in a single request, since
System One evaluates questions in parallel. If no matching rule has a
when: clause, no network call is made at all, so a policy that uses only
commands:/paths: is exactly as fast as before.
Answers are cached on disk (default: 24h, in your user cache directory), so
the commands an agent repeats all day — go build, git status — are judged
once. Set --typesafe-cache-ttl 0 to disable.
Set your API key as an environment variable, never as a flag and never in the policy file:
export HOOKMON_TYPESAFE_API_KEY=...The policy file is meant to be checked in and shared, and the hook command
line lives in .claude/settings.json, which is also checked in — a key in
either would be committed. hookmon never reads an API key from the policy
file. ~/.hookmon.yaml works too; chmod 600 it.
| Setting | Flag | Default |
|---|---|---|
HOOKMON_TYPESAFE_API_KEY |
(none, by design) | unset — judgments never fire |
HOOKMON_TYPESAFE_ENDPOINT |
(none) | https://api.typesafe.ai/v1/systemone |
HOOKMON_TYPESAFE_MODEL |
--typesafe-model |
jev-latest |
HOOKMON_TYPESAFE_TIMEOUT |
--typesafe-timeout |
3s |
HOOKMON_TYPESAFE_CACHE_TTL |
--typesafe-cache-ttl |
24h |
HOOKMON_TYPESAFE_CACHE_DIR |
--typesafe-cache-dir |
user cache dir |
HOOKMON_TYPESAFE_SEND_CONTENT |
--typesafe-send-content |
false |
This is the one part of hookmon that talks to a third party, and only for
events that a when: rule already matched. hookmon sends the agent name, the
event name, the tool name, cwd, and the tool's tool_input.
File and message content is stripped first. The content, new_string,
old_string, edits, and plan fields are dropped, and every remaining
string is truncated. A Write to .env sends the path, never the secret —
judging what a call does needs the target, not the payload. --typesafe-send-content
lifts this if you want it; leave it off unless you have a reason.
If no API key is set, judgments are not configured, and every when:
rule simply goes inert — on-error does not apply. Without that
distinction a shared policy carrying on-error: ask would prompt on every
matching call for any teammate who has no key. hookmon still warns on stderr,
because a policy that expects judgments is not being enforced.
Once a key is set, a network error, a timeout, a non-2xx response, or a
missing answer all mean the when: clause could not be evaluated. The
rule then takes its on-error action:
allow(the default) — the rule does not fire, exactly like a missing policy file. Fail-open, consistent with the rest of hookmon.ask— degrade to a confirmation prompt.deny— block.
hookmon always warns on stderr naming the judgments it skipped. Note the
trade-off honestly: with the default allow, a network blip silently stops a
deny rule from protecting anything. Use on-error: ask on the rules where
that matters.
on-error applies only when a configured judgment was unevaluable —
never when judgments aren't configured at all, and never when an answer
arrived and simply fell below the threshold.
Policy files are decoded strictly: a field this binary doesn't recognize fails the parse, and hookmon then falls open (allows everything) with a warning, rather than enforcing a policy it only partly understands.
That matters because older hookmon binaries ignore unknown fields
silently. A binary predating when: reads
- tools: ["Bash"]
when: { destroys_work: ">= 0.85" } # invisible to it
action: denyas an unconditional deny of every Bash call — fail-closed, with a reason
string that misleads about the cause. Strict decoding prevents the next
version of this problem but cannot fix binaries already built, so when you
adopt a policy that uses newer fields, rebuild and redeploy hookmon
first. The checked-in hookmon binary is gitignored; each machine builds
its own, so a stale one is easy to miss.
One consequence worth knowing: a typo like tool: for tools: now disables
the whole policy (loudly) instead of silently creating an over-broad rule.
That is the safer direction, but it does mean a typo costs you enforcement
rather than quietly changing it.
Every deny written to --policy-log-file records the judgment that fired
and its value:
{
"command": "rm -r -f ./src",
"action": "deny",
"reason": "This command would irreversibly destroy work.",
"judgment": "destroys_work",
"judgment_value": 0.92,
"judgment_condition": ">= 0.85"
}Thresholds are an application decision, not something to inherit from an example. Pick them against your own traffic.
A judgment narrows a blunt rule; it is not a sole line of defense. Keep exact lookups in code:
- The rule protecting the policy file itself. A model-evaluated guard on its own guard fails open on a network blip.
- Known-secret paths. Keep
paths: [".env"]as a deterministic rule alongside anyexposes_secretsjudgment — belt and braces. - Event, agent, and tool names. Never probabilistic.
- Anything where fail-open is unacceptable. If "the network was down, so
the rule didn't fire" is not survivable, that rule cannot have a
when:.
Fail-open by design, matching every other error path in hookmon: a
missing policy file, a policy file that fails to parse, or a hook/tool that
matches no rule all behave exactly like no policy being configured at all —
they never block anything. Logging is unaffected either way: a denied call
still gets written to --log-file (the raw envelope) and, by default, to
--policy-log-file as a structured decision record; allowed and asked calls
are never written to --policy-log-file.
Every deny decision is additionally appended to --policy-log-file as a
structured JSON record, separate from the raw envelope written to
--log-file:
{
"time": "2026-09-18T12:00:00Z",
"agent": "claudecode",
"event": "PreToolUse",
"tool": "Bash",
"path": "",
"command": "echo hi",
"action": "deny",
"reason": "Direct Bash calls are blocked by hookmon policy."
}Configure the path with --policy-log-file, HOOKMON_POLICY_LOG_FILE, or
policy-log-file: in .hookmon.yaml (default: .hookmon-policy.log in the
current directory). Set it to an empty string to disable this log entirely.
Only deny decisions are recorded — allow and ask never write an entry.
Only a subset of hook events actually support blocking, because each event's real acknowledgment schema differs:
- Claude Code: only
PreToolUse(viahookSpecificOutput.permissionDecision). Other events use a different, unimplementeddecisionschema and are always acknowledged with{}regardless of matching rules. - Cursor:
preToolUse,postToolUse,postToolUseFailure,beforeShellExecution,beforeMCPExecution,beforeReadFile,subagentStart,beforeTabFileRead(via{"permission": ...}).
Copy examples/cursor/hooks.json to ~/.cursor/hooks.json or project .cursor/hooks.json. Point command at your binary:
"command": "/absolute/path/to/hookmon --agent cursor --log-file /absolute/path/to/hooks.log"Wire format:
{ "agent": "cursor", "payload": { /* raw hook JSON */ } }hookmon always acknowledges with {} and fails open (writes a warning to
stderr but still acknowledges) if logging fails.
Claude Code hooks come in two flavors: type: "http" (Claude Code POSTs the
hook JSON straight to a URL) and type: "command" (a local script gets the
hook JSON on stdin). hookmon uses command hooks — hookmon --agent claudecode is the script; it appends straight to a log file, so there's no
separate HTTP server or auth story to build.
Copy examples/claudecode/settings.json
into .claude/settings.json (shared with your team, check it in) or
.claude/settings.local.json (personal-only, usually gitignored) in your
project. It registers every documented Claude Code hook event, matching
tool-related ones with "matcher": "*":
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/hookmon --agent claudecode --log-file ${CLAUDE_PROJECT_DIR}/hooks.log" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/hookmon --agent claudecode --log-file ${CLAUDE_PROJECT_DIR}/hooks.log" }
]
}
]
}
}${CLAUDE_PROJECT_DIR} is set by Claude Code to the project root, so the
config works regardless of where the repo is checked out. No env vars or
secrets are needed — hookmon has no auth.
This repo dogfoods its own Claude Code integration via the committed .claude/settings.json, the same way .agents/hooks.json dogfoods Cursor.
Verify it's working:
go build -o hookmon .- Open this project in Claude Code and run
/hooks— you should seeclaudecodehandlers registered for every event. - Trigger any tool call (e.g. ask Claude to run
ls); the configured--log-fileshould gain an envelope tagged"agent": "claudecode".
You can also test hookmon directly without Claude Code:
echo '{"session_id":"test","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"},"cwd":"/workspace","transcript_path":"/tmp/t.json"}' | ./hookmon --agent claudecode --log-file ./hooks.log
echo '{"session_id":"test","hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"},"tool_response":"hi","cwd":"/workspace","transcript_path":"/tmp/t.json"}' | ./hookmon --agent claudecode --log-file ./hooks.log
cat ./hooks.logBoth should show up in hooks.log tagged "agent": "claudecode".
- Implement
agent.Providerinagent/<name>/ agent.Registerininit()- Blank-import the package from
cmd - Add
examples/<name>/hook config