Hooks
A hook is a command of yours that harness runs at two moments: before a tool call, and when you send a
prompt. It can block the call or the prompt, or (for a prompt) add text the model reads beside it. Hooks
are for your own rules (“never force-push”, “route ooo prompts to a skill”). They are not a security
boundary (below).
Hooks are defined in config.toml (config.md) and read from your user config
only, as MCP servers are. A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project
is refused for [[hooks]].
The table
[[hooks]]
name = "no-force-push" # required; unique; 1–32 of a-z, 0-9, "-", "_"
event = "PreToolUse" # "PreToolUse" or "UserPromptSubmit"
tools = ["bash"] # PreToolUse only; omitted = every tool
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"] # argv; no shell
timeout = 10 # whole seconds, 1–600; default 30
sandbox = true # default true
on_failure = "block" # "block" (default) or "continue"
commandis an argv. Nothing runs through a shell unless you write["sh", "-c", "…"].- Hooks run in the order the file lists them. The first to block ends the run; later hooks do not run.
toolsnames harness’s own tools:bash,exec,read_file,write_file,edit_file,glob,search,skill,agent,script,mcp,mcp_schema. A name that is none of these prints a start-up warning, not an error, so a config written for a later harness still starts:⚠ hook no-force-push lists "Bash", which is not a tool here; tool names are lower case, such as bash.- A load error names the file for: an unknown key, an event other than the two, an empty
command,toolson aUserPromptSubmithook, a duplicate or malformedname, atimeoutout of range. Claude Code’smatcher,typeandifare unknown keys;matchergets a message that says to usetools. - At start the banner lists the hooks:
hooks: no-force-push (bash, exec) · keywords (prompts) · notify (every tool, on the host).-pprints the line on stderr.
PreToolUse: block a tool call
The hook runs when the model’s call reaches the approval gate, after the structural refusals (a tool the
mode does not offer, an unknown argument, the hard-deny list, a plan-mode write outside the plan file)
and before every permission shortcut. So a hook binds under --approve-all and in plan mode, and
never runs for a call that was already refused. It covers every way a call can arrive: an ordinary call,
each call inside a script, an agent spawn, an mcp call, and the calls of sub-agents and the side
session.
Input, one line of JSON on stdin, then end of file:
{"session_id":"01M…","hook_event_name":"PreToolUse","cwd":"/home/dana/proj","permission_mode":"auto","tool_name":"bash","tool_input":{"command":"git push --force origin main"},"tool_use_id":"7EX…"}
permission_modeis the name of the mode in force (manual,plan,auto, or your own mode’s name).session_idis the session running the call: a sub-agent’s own id inside a sub-agent.tool_inputis the call’s arguments, with in-project paths made relative to the project. A call inside ascriptcarries the outerscriptcall’stool_use_id.- An MCP server’s tool is called through the
mcptool, so a hook for one liststools = ["mcp"]and readstool_input.server,tool_input.toolandtool_input.arguments. - The field names are Claude Code’s, without
transcript_path.
What the answer means (in every row a reason is trimmed and cut to its first 2,000 characters; empty or
absent, it is no reason given):
| The hook | Means |
|---|---|
| exits 2 | block; the reason is stderr |
| exits 0, prints nothing | no objection |
exits 0, prints a JSON object with hookSpecificOutput.permissionDecision = "deny" | block; the reason is permissionDecisionReason |
exits 0, prints a JSON object with decision = "block", or continue = false | block; the reason is reason, or stopReason |
exits 0, prints any other JSON object ({}, {"continue": true}, decision = "approve", permissionDecision = "allow", "ask" or "defer", …) | no objection |
exits 0, prints text that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, or permissionDecision, decision or continue of another kind or word | failure (bad_output) |
| any other exit code | failure (exit) |
| killed by a signal; timed out; could not start | failure (signal, timeout, spawn_error) |
allow and ask mean no objection. A hook only ever blocks. The call goes on through the judge, the
sandbox and any question to you, as if the hook had not run. Other JSON fields (systemMessage,
additionalContext, …) are ignored on PreToolUse.
A block is a failed call. The model reads blocked by the user's hook "no-force-push": <reason>, the turn
goes on, and under -p the call is named on stderr with the other failed calls. It is not in denied
(that lists calls that needed approval).
UserPromptSubmit: add context to a prompt, or block it
The hook runs for each prompt that opens a turn in the main session: typed in the panel or the plain
prompt (a /skill:name … message included), given to -p, or a harness eval case’s prompt. It does
not run for a message you steer in while the agent works, a side question, a sub-agent’s prompt, or a
background sub-agent’s result.
{"session_id":"01M…","hook_event_name":"UserPromptSubmit","cwd":"/home/dana/proj","permission_mode":"auto","prompt":"ooo seed my requirements"}
There is never a turn_id (Ouroboros’s detector reads one as the mark of a Codex host).
| The hook | Means |
|---|---|
| exits 2 | block the prompt; the reason is stderr |
| exits 0, prints nothing | nothing added |
exits 0, prints a JSON object with decision = "block", or continue = false | block; the reason is reason, or stopReason |
exits 0, prints a JSON object with a string hookSpecificOutput.additionalContext | that string is the context |
exits 0, prints any other JSON object (decision = "approve" included) | nothing added |
exits 0, prints text whose first non-space character is not { | the trimmed text is the context |
exits 0, prints text starting { that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, a hookEventName that is not "UserPromptSubmit", additionalContext that is not a string, or decision or continue of another kind or word | failure (bad_output) |
| any other exit, signal, timeout, start failure | failure, as above |
- Context reaches the model as a text block after your prompt, framed
Context from the user's hook "keywords":and the text. Over 10,000 characters it is cut to the first 10,000 and a warning says so. Several hooks run in file order; if one blocks, none of their contexts is kept. - A blocked prompt never reaches the model, nor any later request. The panel and the plain prompt
print
✗ prompt blocked by hook "keywords": <reason>, or for a failure✗ prompt blocked: hook "keywords" failed (timed out after 5 s). The text stays in your input history (↑). In a session that has a prompt hook, a prompt whose turn was interrupted or killed before the model saw it is dropped the same way. - Context is text beside the prompt. It approves nothing: the gate is untouched.
Under -p a blocked prompt exits 1 (outcome prompt_blocked, headless.md). In
harness run it is not a failure of the session, as a denied call is not.
When a hook fails
A failure is anything the tables above call one. on_failure decides what it does.
on_failure = "block"(the default): the call or the prompt is blocked. For a call, the model reads a fixed message, not the hook’s output:the user's hook "no-force-push" failed (timed out after 10 s), so this call was blocked. This is not a policy decision: retrying or rephrasing the call will not help. Tell the user their hook is failing.A broken hook therefore stops every call it covers; harness never lets a broken policy pass unnoticed. A prompt that is blocked this way shows✗ prompt blocked: hook "keywords" failed (timed out after 5 s).on_failure = "continue": the hook is skipped and the next one runs. Use it for hooks that advise and do not enforce.
Either way you get one warning per hook per turn (on stderr as harness: … under -p):
⚠ hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks.
⚠ hook no-force-push failed (exited with code 1); on_failure = "continue" let the call through.
For a UserPromptSubmit hook the first reads … so your prompts are blocked. … and the second … let the prompt through.
The kinds read timed out after N s, exited with code N, printed output that is not hook JSON,
could not start: <error> and was killed by a signal. Esc during a hook stops it (its whole process
group) and ends the turn interrupted; the call does not run.
Where to keep hook files
Keep a hook’s program and any file it reads outside the project, and outside any folder the agent can
write. An agent that can edit its own policy hook has no policy, so at start harness resolves
command[0] and each later element that names an existing file or folder (relative paths from the project
root; an argument that does not exist but is shaped like a path, with a / and no spaces, is checked
against the writable folders too, since the agent could create it; the value after an option’s first =,
as in --rules=hooks/r.yaml, is checked the same way; an empty value, --exclude=, names nothing and is not
checked), and refuses to start the session when one lies in:
- a folder the agent can write: the project, a linked worktree’s common directory, the private temp
folder,
[sandbox] writable, and on macOS the user temp folder. Under bubblewrap the box’s/tmpis the agent’s, so a sandboxed hook’s path under/tmpis refused; without a sandbox the system temp folders (/tmp, and on macOS/private/tmpand$TMPDIR) count; - for a hook with
sandbox = truein a session with a sandbox, a path the sandbox hides (sandbox.md): the sandboxed hook could not read it.
Each path, command[0] included, is checked as written and where it leads: every link on the way is
followed, one whose target does not exist yet included, and a link or folder on the way that sits inside a
folder the agent can write counts too, since the agent could re-point it.
The refusal names the hook, the path and the fix:
hook no-force-push: /home/dana/proj/hooks/check.sh is in the project, so the agent could change it. Move it outside (for example ~/.local/share/harness/hooks/), or start with --no-hooks.
hook no-force-push: /home/dana/.claude/check.sh is in a folder the sandbox hides (/home/dana/.claude), so the hook could not read it. Move it, or set sandbox = false for this hook.
hook no-force-push: check.sh was not found.
~/.local/share/harness/hooks/ is a good place: it is the data root, read-only to sandboxed commands. A
command[0] with no / is looked up on PATH once, at start; one with a / must be absolute. The check
is best effort: a path inside a ["sh", "-c", "…"] string is not seen, and a value that only looks like a
path (--header=Content-Type:application/json) is refused as one. A sandboxed hook runs with the project as
its working directory, so it can still be steered by a bare relative name that was absent at start
(["python3", "hook.py"]) or by an interpreter string (-c, -e, -m: python3 -c "import json" loads a
json.py from there). Such a hook can only decide its own block, never reach the host.
Sandbox, or the host
sandbox = true(the default): the hook runs in the same box asbashandexec, in the project root: it can read widely, write only where commands can, has no network and no provider key.sandbox = false(or any session with no sandbox): the hook runs on your machine with your user’s access, from/, with every provider key removed from its environment. Use it for a hook that needs the host (a desktop notification, a policy server).
A host hook runs from / so that nothing it or its interpreter loads by a relative name comes from the
project. Its input’s cwd still names the project; a host hook that needs it goes there first:
cd "$(jq -r .cwd)". In a session with a sandbox, a host hook’s PATH also loses every empty or relative
entry and every entry inside a folder sandboxed commands can write (the project, a linked worktree’s common
directory, the private temp folder, [sandbox] writable, and on macOS the user temp folder), so a program
the agent plants there is not found by name. An entry that cannot be traced to where it leads (a link to
nowhere yet) goes too, and if none is left the hook runs with no PATH at all: name its programs by absolute
path. With no sandbox PATH is unchanged: the agent’s commands can write anywhere.
A host hook that runs project files (cargo, npm test, a repo script) runs code the agent can write.
Keep such hooks sandboxed, or have them run only files outside the project. The banner marks a host hook
on the host.
A hook should not write files. Its writes are not tracked on their own, so /undo may not undo them, and
a host hook’s write by a relative name fails at /.
Turning hooks off: --no-hooks
harness run --no-hooks and harness resume <id> --no-hooks run that segment with no hooks. The banner
reads hooks: off (--no-hooks), and the log records it. A later resume without the flag runs the hooks
again. It is the way out of a hook that is failing closed.
What is recorded
- The definitions:
session.startedandsession.resumedcarry each hook’s name, event, command, tools, timeout,sandboxandon_failureas the session ran them, and whether--no-hookswas given. - Each run is an effect with the hook’s input digest and its output, so a replay serves it and starts nothing.
- Each block, failure, interrupt or skipped run is a
hook.decidedrecord with the hook, the tool, the reason or the failure kind. A run with no objection leaves only its effect. - Context is a
hook.contextrecord, kept in the log and shown in transcripts and exports as· hook keywords added context (171 characters). harness session --transcript, a resume’s replayed rows and the exports show the same rows as the live panel, plus· hook <name> failed (<kind>); the call went ahead(the prompt went aheadfor a prompt hook) for a failure thatcontinuelet through.harness sessions --jsongivesoutcomeprompt_blockedfor a session whose last turn was a blocked prompt, and the/sessioncard counts them.
harness eval loads your hooks from your user config and runs them for each case’s prompt and tool
calls, as -p does. A case whose prompt a hook blocked ends prompt_blocked: it fails an
expect.outcome.in of ["completed"] and can expect ["prompt_blocked"].
A crash resume (resuming a session that was killed) keeps the hooks its log recorded, which its
hooks: line names, and runs a recorded hook only if today’s config still defines it exactly as recorded: same event, command, tools,
timeout, sandbox and on_failure. Otherwise it is skipped as no objection, with a warning
(⚠ hook no-force-push is not run in this session: it is changed in your config since the session was recorded. Resume cleanly to use today's hooks.). A log can never make harness run a program your config
does not name. A clean resume reads today’s config and records what applies from then on. A session killed
while a hook ran says so on resume: This session was killed before bash ran. It never ran. or This session was killed while a prompt hook ran. The prompt was not sent.
Example: refuse force-pushes
[[hooks]]
name = "no-force-push"
event = "PreToolUse"
tools = ["bash", "exec"]
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"]
timeout = 10
#!/bin/sh
# Reads the call as JSON on stdin. Exit 2 blocks; stderr is the reason the model reads.
input=$(cat)
case "$input" in
*"git push"*"--force"*|*"git push"*" -f"*)
echo "force-push is not allowed here" >&2
exit 2 ;;
esac
exit 0
This reads the raw JSON as text, which is enough for a demonstration and easy to route around (see below).
A hook that needs the fields parses the JSON (python3, jq).
Example: a hook for MCP tools
An MCP server’s tool is called through the mcp tool, so cover it with tools = ["mcp"]:
[[hooks]]
name = "mcp-audit"
event = "PreToolUse"
tools = ["mcp"]
command = ["python3", "/home/dana/.local/share/harness/hooks/mcp-audit.py"]
The input’s tool_input is {"server": "github", "tool": "create_issue", "arguments": {…}}.
Example: Ouroboros’s keyword detector
Ouroboros ships a UserPromptSubmit hook, keyword-detector.py. It
reads the JSON above and, for a prompt such as ooo seed my requirements, answers with
hookSpecificOutput.additionalContext holding a <skill-suggestion> block that names a skill
(/ouroboros:seed). It suggests the skill and loads nothing; the model decides. Harness accepts that
JSON as it is. Ouroboros’s hooks are written to exit 0 whatever happens, so they fail open on their own.
Copy the script to ~/.local/share/harness/hooks/ (outside the project) and add:
[[hooks]]
name = "keywords"
event = "UserPromptSubmit"
command = ["python3", "/home/dana/.local/share/harness/hooks/keyword-detector.py"]
timeout = 5
on_failure = "continue"
on_failure = "continue" keeps an advisory hook from blocking your prompts if Python is missing.
Ouroboros’s other hooks (SessionStart, PostToolUse) have no harness event yet. Its skills load through
[skills] extra_roots (config.md), and a skill the
model loads in auto runs without a question.
Differences from Claude Code
| Claude Code | harness | |
|---|---|---|
| where hooks are defined | settings files, including the project’s | your user config only |
| command | a shell string | an argv; a shell only if you write one |
| where it runs | on your machine, unsandboxed | in the sandbox by default; sandbox = false for the host |
| working directory | the project | the project in the sandbox; / on the host (the input’s cwd names the project) |
| when a hook fails | the call goes ahead | the call is blocked (on_failure = "block"); "continue" to let it through |
| which calls | matcher patterns | tools, harness’s tool names; no matcher |
| tool names and arguments | Bash, Edit, Write; file_path, old_string, new_string | bash, edit_file, write_file; path, old, new, content |
ask, allow answers | can grant or force a question | no objection: the gate decides as if no hook ran |
| events | many | PreToolUse, UserPromptSubmit |
| files the hook reads | anywhere | outside the project and outside any folder the agent can write |
The envelope’s field names and the answer formats are Claude Code’s. A ported script must change any tool
or argument name it matches on: one that tests for Bash or file_path finds nothing here and objects to
nothing, silently. A script that only reads tool_input.command ports as it is.
Hooks are not a security boundary
A policy hook sees one call at a time and can be routed around: a rule against git push --force does
not see git push origin +main, a script in the repo that pushes, or the same push spelled through an
interpreter. The sandbox is the boundary. Use hooks for your own conventions and guardrails, and keep
secrets and destructive reach out of the agent’s hands with the sandbox, the modes and the hard-deny list
(modes.md, sandbox.md).