Lifecycle hooks and policy automation
Lifecycle hooks and policy automation
Lifecycle hooks run small, declarative automations at stable boundaries in an agent session. Use
them to deny unsafe actions, request an approval, format files, run tests, notify another system,
or preserve an audit record. Hooks add policy; they never replace the normal tool, path,
information-flow, governance, or approval checks.
Manage hooks in Settings → Lifecycle Hooks or with holaryn hook. Settings exposes the
effective registry, validation errors, project trust, a dry-run order preview, and redacted
execution traces. Changes, disables, and removals take effect on the next boundary without
restarting the host.
Lifecycle boundaries
Schema version 1 defines these exact hook points:
| Point | Mode | Mutable field |
|---|---|---|
session.start, session.end |
Observational | None |
prompt.before |
Blocking or observational | text |
prompt.after |
Observational | None |
model.before |
Blocking or observational | metadata |
model.after |
Observational | None |
tool.before |
Blocking or observational | args |
tool.after, tool.error |
Observational | None |
approval.request |
Blocking or observational | None |
approval.decision |
Observational | None |
file.before_write |
Blocking or observational | args |
file.after_write |
Observational | None |
command.before |
Blocking or observational | args |
command.after |
Observational | None |
checkpoint.created, checkpoint.restored |
Observational | None |
integration.before |
Blocking or observational | strategy |
integration.after |
Observational | None |
A normal tool call reaches tool.before and then its more specific file or command boundary.
Direct slash commands, @codex/@claude-code, MCP App calls, and calls made through
run_tool_script follow the same boundaries. A denial is emitted as a visible error before the
tool runs. Worktree integration invokes integration.before only after the candidate, exact plan,
validation evidence, target revision, and explicit integration approval pass their normal checks.
Blocking hooks run serially. They may return:
allowto continue;denywith a reason;modifywith only the field allowed in the table above; orrequest_approvalto enter the ordinary approval flow.
Every mutation is schema checked and the resulting action is evaluated again by the normal
approval, path, tool, and governance policies. A hook cannot rename the selected tool, manufacture
an approval decision, widen a filesystem root, or grant itself credentials. Invalid mutations fail
according to the hook's configured failure policy and are traced.
Observational hooks cannot modify or deny: a decision other than allow, or any mutation,
from an observational hook is refused, traced as an error, and handled by its failure policy. They are scheduled asynchronously so a slow formatter,
test, notification, or webhook does not park unrelated sessions. Session shutdown performs a
bounded drain of outstanding observers.
Scope, override, and order
Owner hooks merge from least to most specific:
globalprofileprojectsession
When a more-specific scope uses the same hook id, it replaces the less-specific definition.
Plugin hooks never take part in that override: their ids live in their own namespace,
plugin:<plugin>/<hook-id>, which no other scope may use, so a plugin hook can never replace or
shadow an owner, profile, project, or session hook.
At each hook point, every plugin hook runs before the owner's hooks, whatever its order. Plugin
hooks sort among themselves by order, then id; the other hooks then sort by ascending order,
then scope precedence (global, profile, project, session), then id. Because owner hooks run last,
an owner's blocking hook always sees, and can deny, whatever a plugin hook changed.
Filters are conjunctive across families and support tool-name globs, exact consequence categories,
path globs, session globs, and exact data values. Each blocking hook's filters are matched against
the event as the hooks before it at that point changed it, so an owner guard filtered to
protected/** runs when an earlier hook rewrites a path into protected/. Observational hooks see
the final event. The Settings registry and holaryn hook dry-run show the resulting order; a
dry-run matches filters against the event you give it and does not run handlers, so it cannot show
matches that depend on a rewrite.
Global and profile definitions live in the Holaryn state directory. Project definitions live at
.holaryn/hooks.json. Plugin hooks come from a plugin's hooks.json; session hooks are ephemeral.
Project trust
Opening a repository does not execute its hooks. A project hook file must validate and then be
trusted explicitly. Trust binds the canonical workspace path, hook-file path, and the exact
SHA-256 digest. Editing the file invalidates trust immediately.
holaryn hook validate .holaryn/hooks.json
holaryn hook trust-status .
holaryn hook trust .
holaryn hook trust-status .
Use holaryn hook revoke-trust . to disable the executable trust decision. In Settings, select the
workspace, review the displayed path and digest, then use Trust exact digest. The UI reports
when a file changed after review.
Project process handlers run only when the configured execution backend provides workspace
isolation. A local, non-isolating backend refuses them. This prevents a repository from using a
hook as ambient host code.
Plugin hooks and consent
A plugin may ship a hooks.json next to its holaryn-plugin.toml. Its hook ids must be bare ids;
Holaryn namespaces them as plugin:<plugin>/<hook-id>. Plugin hooks are inert until you consent
to exactly their content:
- The hooks digest is SHA-256 over the canonical JSON of the plugin's hook document together
with the SHA-256 of every script it references. Formatting changes tohooks.jsonkeep it; any
content change, including an edit to a referenced script, produces a new digest. - Enabling a plugin that ships hooks shows every hook (events, handler, command, script or
built-in, and mode) and the full digest, and the approval carries that digest. If anything
changed after it was shown, the request is refused (HTTP 409 in Settings) and you review the new
content. - Consent is stored per plugin in
plugins-state.jsonand read again at every hook boundary.
Editing the plugin'shooks.json, revoking consent, or disabling the plugin pauses its hooks at
the next boundary, including in running sessions. Updating or reinstalling a plugin keeps the
earlier consent, so changed hooks show as changed and stay paused until you approve the new
digest. - Plugin hooks run only in sessions whose execution backend isolates the workspace, such as
Docker. On a local backend every plugin hook is refused and traced. - What a plugin hook may run is limited, and a document that breaks a rule is refused whole (the
plugin card and the registry show why): scripthandlers must name a regular.pyor.shfile (UTF-8 without a byte-order mark, at
most 16 KiB, not reached through a link or junction) inside the plugin's own installed
directory, never in the session workspace. Just before launch the executor reads the script
again and refuses it if its SHA-256 differs from the approved one. The checked bytes travel
to the isolated backend on standard input, ahead of the event, never on the command line; a
fixed launcher reads exactly those bytes (never the event), writes them into a new private
temporary directory, changes into it, and runs them there. The event stays on standard input,
sosys.stdin,os.read(0, …),cat, and child processes that inherit standard input all
receive it. Python scripts run as the real__main__module, sopickleanddataclasses
treat their classes as usual. The launcher removes the private directory when the script
exits and keeps its exit status.- Shell scripts must use LF line endings; a
.shscript with CRLF line endings is refused before
consent, becauseshon Linux cannot run it. - The isolated backend image must provide
python(the command name, not onlypython3) for
.pyscripts, andsh,mktemp,dd,wcandrmfor.shscripts. A missing program
fails the hook with an error that names what the image must provide. - Plugin scripts must be self-contained. Python scripts run in isolated mode without
site
(python -I -S): no workspace, current-directory, plugin-sibling or site-packages modules and
noPYTHON*variables, so only the standard library can be imported. Shell scripts start in
the same empty private directory, so relative.sourcing and relative paths find nothing
from the workspace or the plugin. Consent pins the script itself; a script that opens or runs
files by absolute path uses content that consent does not pin. commandhandlers must name a program on the isolated backend'sPATH, not a file path.
Consent covers the exact command line; the program itself comes from the backend's image, and
if its arguments name files in the workspace, those files are not pinned by consent.webhookhandlers are refused: a webhook would run on the host, outside the isolated backend.- Plugin hooks cannot set environment variables (
env): a variable such asLD_PRELOADcould
make a program load unreviewed code. Pass settings as arguments. builtinhandlers use Holaryn's fixed set of reviewed in-process handlers (audit,
forbidden-path,notification,secret-scan), which read the event and decide; for plugins
they are admitted only in the same isolated sessions.- A plugin hook may change a tool call's arguments only at
tool.before. That point runs before
the owner's hooks attool.before, before every hook at the laterfile.before_writeand
command.beforeboundaries, and before the approval check, so owner policy and approval always
judge the final arguments. At every other point a plugin hook can allow, deny, or request
approval; a change it returns there is refused, traced, and handled by its failure policy. - A document that breaks one of these rules is shown as Not loaded (on the plugin card, in
the registry, and after install, enable or update) with a message that says what to use
instead; nothing from it can run or be approved until the author fixes it. - Hook documents reject non-finite numbers, and the plugin namespace is refused in managed,
project and session documents whatever scope they declare.
Review and approve plugin hooks in Settings → Plugins & Skills. From the CLI,
holaryn plugin inspect <name> prints the hooks and their digest, and holaryn plugin enable
<name> asks for consent (with --yes, pass --hooks-digest <digest>); run enable again to
approve changed hooks. The Settings registry, holaryn hook list, and holaryn hook dry-run list
every plugin's hooks with their consent state: pending, approved, changed, or disabled.
Handler types and payloads
A hook handler is one of:
builtin: reviewed handlers shipped with Holaryn;command: an argv array with no shell parsing;script: a workspace-contained script path;webhook: an HTTPS URL, or loopback HTTP for local development.
Process handlers receive one JSON event on standard input and must write one JSON decision to
standard output. They receive a minimal sanitized environment plus explicitly declared,
non-secret environment values. Secret-like environment names are rejected. A handler that runs
in a container (in a Docker session; project and plugin process hooks always do) receives only
its declared values, PYTHONIOENCODING, PYTHONUTF8 and the HOLARYN_HOOK_* event variables,
nothing from the host: the image supplies PATH, HOME, the locale and the temporary
directory, so declare a value the hook needs there. On Windows the environment of a handler
that runs on the host also sets NoDefaultCurrentDirectoryInExePath=1. That switch turns off
only the implicit search of the current directory: a program the hook starts by name (a command run
through cmd.exe, an npm shim finding node) is no longer looked up in its working directory,
the workspace, before PATH. That switch does not change PATH, so Holaryn also removes,
on every platform, each entry of the PATH a host-run handler receives or sets that names
its working directory: an empty entry (a leading, trailing or doubled separator), ., a
relative entry that resolves to it, such as bin/.., or its full path. On Windows it reads
an entry the way cmd.exe, Node, PowerShell and the system's own program search do, so an
entry in double or single quotes (".", '.', C:\"project"), with leading spaces, or with
~ for a profile folder that is the working directory counts too; an entry of spaces does
not. Each run that removes one logs a warning naming the hook and the kind of each
removed entry, never its text, such as Hook lint: removed 1 PATH entry naming its working
directory (a dot entry); programs are looked up elsewhere on PATH. (in the host log, or the
terminal's error output for holaryn run). A hook that ran a program from
its working directory through . or an empty entry must now name the program's path
(./build.sh, .\build.exe) or put the program in a subdirectory and list that subdirectory
(bin) instead. A relative entry that names
another directory, such as node_modules/.bin, is kept and still resolves against the
working directory, so a program there stays reachable; keep relative entries out of a hook's
PATH when it should not run programs from the workspace. Output, time,
concurrency, and process-tree lifetime are bounded. After three consecutive failures, the
per-session circuit opens. The recursion depth is limited to three.
The input contract is:
{
"schema_version": 1,
"event_id": "hookevt_...",
"timestamp": 0,
"point": "tool.before",
"session_id": "sess_...",
"run_id": "run_...",
"trace_id": "trace_...",
"workspace": "/reviewed/workspace",
"depth": 0,
"data": {
"call_id": "call_...",
"tool_name": "write_file",
"args": {"path": "notes.md", "content": "hello"},
"category": "fs.write",
"reversible": false
}
}
A blocking handler can respond:
{
"schema_version": 1,
"action": "modify",
"reason": "normalize the destination",
"mutations": {
"args": {"path": "docs/notes.md", "content": "hello"}
},
"output": {"rule": "documentation-path"}
}
Webhook redirects are disabled. Credential-bearing headers are rejected. Resolution blocks
link-local and private destinations except explicit loopback development URLs. Handler output and
errors are secret-redacted before persistence or event emission.
Definition example
This global blocking hook denies common credential and VCS-internal file paths:
{
"schema_version": 1,
"id": "protect-sensitive-paths",
"name": "Protect sensitive paths",
"description": "Deny writes to credential and VCS-internal paths.",
"point": "file.before_write",
"scope": "global",
"scope_key": null,
"enabled": true,
"mode": "blocking",
"order": 10,
"timeout_seconds": 10,
"max_output_bytes": 65536,
"max_concurrency": 1,
"failure_policy": "deny",
"filters": {},
"handler": {
"kind": "builtin",
"builtin": "forbidden-path"
}
}
The forbidden-path builtin denies .env and .env.* files, anything under .git or .ssh,
and names containing credentials or private-key. It checks the file the write would reach,
not only the text of the path: a relative path (and on Windows a path starting with \) is
taken from the session workspace as the file tools take it, .. is folded, and links, junctions
and 8.3 short names are followed. On Windows and macOS other casings, trailing dots or spaces and
an NTFS stream suffix (.env::$DATA) match too. A target inside the workspace is matched by its
path from the workspace root, so a workspace whose own folder name contains one of these words
still allows ordinary files. Every argument of the write that names a path is checked, not only
path: file_path, output_path, outputFile, destination, paths and similar names,
including the paths in lists.
Print complete copyable examples for forbidden paths, proposed-secret approval, a formatter,
test-on-completion, and notifications:
holaryn hook examples
Save one printed hook as hook.json, then:
holaryn hook validate hook.json
holaryn hook add hook.json
holaryn hook list
holaryn hook dry-run file.before_write --data-json "{\"tool_name\":\"write_file\",\"category\":\"fs.write\",\"args\":{\"path\":\".env\"}}"
holaryn hook disable protect-sensitive-paths
holaryn hook enable protect-sensitive-paths
holaryn hook remove protect-sensitive-paths
Add --profile PROFILE_ID to manage a profile definition. Use --workspace PATH with list or
dry-run to include trusted project hooks. Both always include approved hooks of enabled plugins;
list also returns a plugins group per installed plugin with its hooks and consent state. install-project WORKSPACE FILE writes a normalized
project document but deliberately does not trust it.
Failure behavior and diagnostics
failure_policy is one of:
continue: record the failure and continue;warn: continue and surface the diagnostic;deny: fail closed at a blocking boundary;disable: disable the failing hook for the rest of that service lifetime.
Observational hooks cannot use deny. Removing or disabling a hook is safe while other executions
are in flight: current bounded work finishes, while the next registry snapshot excludes it.
Every attempted handler execution creates a versioned, redacted trace with event and execution
IDs, point, scope, duration, decision, exit status, timeout/truncation flags, and safe error text.
Inspect it in Settings or with:
holaryn hook traces --limit 50
holaryn hook traces --hook-id protect-sensitive-paths
holaryn hook traces --point file.before_write
Registry changes also emit diagnostics with the previous and current content-addressed versions.
Hook traces do not contain inherited credentials and are bounded by the local trace store.