You are reading Nightly documentation for 0.12.6.dev0+g563af09.

This documentation may describe behavior that differs from Stable.

Open Stable documentation

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:

  • allow to continue;
  • deny with a reason;
  • modify with only the field allowed in the table above; or
  • request_approval to 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:

  1. global
  2. profile
  3. project
  4. session

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.

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 to hooks.json keep 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.json and read again at every hook boundary.
    Editing the plugin's hooks.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):
  • script handlers must name a regular .py or .sh file (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,
    so sys.stdin, os.read(0, …), cat, and child processes that inherit standard input all
    receive it. Python scripts run as the real __main__ module, so pickle and dataclasses
    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 .sh script with CRLF line endings is refused before
    consent, because sh on Linux cannot run it.
  • The isolated backend image must provide python (the command name, not only python3) for
    .py scripts, and sh, mktemp, dd, wc and rm for .sh scripts. 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
    no PYTHON* 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.
  • command handlers must name a program on the isolated backend's PATH, 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.
  • webhook handlers are refused: a webhook would run on the host, outside the isolated backend.
  • Plugin hooks cannot set environment variables (env): a variable such as LD_PRELOAD could
    make a program load unreviewed code. Pass settings as arguments.
  • builtin handlers 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 at tool.before, before every hook at the later file.before_write and
    command.before boundaries, 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.