@hasna/hooks
v0.13.0
Published
Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command
Maintainers
Readme
@hasna/hooks
Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command
What it does
@hasna/hooks installs hooks into AI coding agents. A hook is a small program the agent runs at a fixed point: before a shell command, after a file edit, or when a turn ends. Hooks block a mistake, check the work, or automate a step. One CLI installs, lists, updates and removes them.
Agents. Claude Code (the default target) and Codex. Compatible OpenCode and Sumi builds are supported for the stop-review hook. Gemini CLI is an install target (
--target gemini), but the bundled shell guards do not yet recognise Gemini's tool names (run_shell_commandrather thanBash), so do not rely on them there yet. Codewith targets remain for existing setups, but Codewith is deprecated.Install targets.
--targetalso acceptsqwen,muse,kimi,piandcursor, each writing that harness's own config with an entry that callshooks run <hook> --target <name>. They are not interchangeable: Muse hands over the final message as a quoted JSON string, whichhooks run --target museparses once; Kimi's stop payload carries no assistant text and Kimi blocks with exit 2 and stderr; Pi runs hooks through thehasna-pi-hooksbridge and receives no assistant text; Cursor's stop hook can neither block the turn nor carry the assistant text. A hook whose manifest setsrequires_final_messageorrequires_blockis refused at install on a target that cannot honour it, with the measured reason, instead of being installed to do nothing.opencodeandsumihave no generic install path and are refused by name; their stop review is registered throughhooks install stop-review --target <opencode|sumi>.Catalog. 57 bundled hooks in 10 categories, listed below. You can also install your own hooks from a local directory, a git URL or a manifest URL.
Safety core. Guards that:
- block destructive git commands (gitguard);
- send
rmto a recoverable trash store instead of deleting (trash-guard). It needs thetrashCLI from@hasna/trashon PATH; without it, trash-guard refuses therm; - protect repository checkouts (workspace-repos-guard);
- keep signed links out of agent transcripts (signed-link-guard).
scanoutput is detection only: it warns about a secret in tool output after the output is already in the transcript, and cannot prevent or redact it.
Each is installed as a hook. For Claude Code, Codex and Sumi,
hooks safety install trash-guard --target <codex|claude|sumi>can also register trash-guard, workspace-repos-guard and signed-link-guard as one native safety entry; the other guards are not part of it.--targetdefaults tocodex, so name the agent.Interfaces. The
hooksCLI, an MCP server (hooks-mcp) and a self-hostable hook registry (hooks-serve).
Quick start
The CLI runs on Bun 1.0 or later, so bun must be on PATH. Without a Hasna hooks registry credential, use local mode: the bundled catalog and a local store in ~/.hasna/hooks. Set it in your shell profile, not only in the current shell. The agent runs each installed hook as hooks run <name>, and that command needs the same setting. Without it the hook exits with REMOTE_API_CONFIG_MISSING and does not guard anything.
bun --version # Bun 1.0 or later is required
npm install -g @hasna/hooks
echo 'export HASNA_HOOKS_LOCAL=1' >> ~/.zshrc # or ~/.bashrc; then open a new shell.
# Only without a registry credential: the opt-in takes precedence over Keychain and file credentials.
hooks list --all --verbose # browse the full catalog
hooks install gitguard --dry-run # preview what would be installed
hooks install gitguard # Claude Code picks it up live; restart if /hooks does not list it
hooks install gitguard --target codex # or Codex; then review and trust it in Codex's /hooks
hooks list --installed --target codex # what is installed for one agent
hooks doctor # check the Claude Code installation
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' \
| hooks run gitguard # a simulated command: prints a "block" decision, runs nothingStarting the commands. The hooks, hooks-mcp and hooks-serve commands start through #!/usr/bin/env -S bun --config=/dev/null --no-env-file --no-macros --no-install. Started this way, they do not read the working directory's bunfig.toml or .env, so a project an agent works in cannot run code inside them. Starting them as commands needs GNU coreutils 8.30 or later, or the macOS/BSD env. That covers PATH, an npm or Bun bin link, bun x and bunx. The flags are verified with Bun 1.3.14, which silently ignores flags it does not know, so an older Bun may start the commands without one of these protections.
With BusyBox env (for example on Alpine) or older coreutils, start them through Bun with the same flags:
bun --config=/dev/null --no-env-file --no-macros --no-install <package>/bin/index.jsforhooks;- the same with
bin/hooks-mcp.jsorbin/serve.jsfor the others; <package>is$(npm root -g)/@hasna/hooksfor an npm global install.
Without the flags, Bun reads the working directory's bunfig.toml and .env. Agents run installed hooks as hooks run <name>. On such systems, put a hooks wrapper on PATH: a #!/bin/sh script that runs exec bun --config=/dev/null --no-env-file --no-macros --no-install <package>/bin/index.js "$@". Otherwise every hook installed as hooks run <name> fails at each event and guards nothing. The native-safety guards are registered with a pinned runtime and do not go through this shebang. For MCP, use "command": "bun" with those flags and <package>/bin/hooks-mcp.js as its arguments.
Prompt for your agent
Paste this to your coding agent and it sets the hooks up itself:
Set up @hasna/hooks (https://www.npmjs.com/package/@hasna/hooks) for this coding agent.
1. Confirm `bun --version` prints 1.0 or later; if Bun is missing, ask me to install it from
https://bun.sh. Install the CLI with `npm install -g @hasna/hooks` and confirm
`hooks --version` runs. Then run `hooks list`. If it prints REMOTE_API_CONFIG_MISSING, no
registry credential is configured: add `export HASNA_HOOKS_LOCAL=1` to my shell profile, tell
me to restart you from a new shell so the hooks you run inherit it, and until that restart
prefix every `hooks` command below with `HASNA_HOOKS_LOCAL=1`.
2. Run `hooks list --all --verbose` and choose hooks that fit this project. Include gitguard
and protectfiles.
3. Preview with `hooks install <names> --dry-run`. Add `--target codex` if you are Codex.
Then run the same command without `--dry-run`. If you are Codex, ask me to review and
trust the new hooks in Codex's `/hooks` screen. If you are Claude Code, the new hooks normally
take effect at once; if `/hooks` does not list them after a few seconds, ask me to restart
this session. An overlapping-matcher warning when two chosen hooks watch the same tool needs
no action. A "Sumi native-safety binding was not provisioned" note appears when that binding
cannot be set up, for example without a verified Trash guard: ignore it if this machine does
not run Sumi, and otherwise show it to me.
4. Run `hooks list --installed --target <claude|codex>`. If you are Claude Code, also run
`hooks doctor`, which checks Claude Code settings only.
5. Check gitguard without running anything destructive: pipe a simulated command into it with
`echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | hooks run gitguard`
(from the restarted shell). It must print a "block" decision.
Done when the chosen hooks are listed as installed for this agent (and, for Claude Code,
`hooks doctor` reports them healthy; for Codex, I have trusted them in `/hooks`), and step 5
prints "block". Never test a guard by running the real destructive command.
Do not ask me for, or print, any credential.Hook catalog
Run hooks list --all --verbose for each hook's description, or hooks info <name> for one hook.
| Category | Hooks | |---|---| | Git safety | gitguard, branchprotect, checkpoint, worktree-guard, workspace-repos-guard, trash-guard, conflict-detect | | Code quality | checktests, checklint, checkfiles, checkbugs, checkdocs, checktasks, stylescheck, typecheck-gate, affected-tests | | Security | checksecurity, packageage, pre-bash, scanoutput, signed-link-guard, signed-link-output | | Permissions | permissionguard, protectfiles, promptguard, prompt-guard | | Context management | contextrefresh, precompact, knowledge-context, mementos-context | | Workflow automation | autoformat, autostage, tddguard, failure-to-task | | Environment | envsetup | | Notifications | phonenotify, agentmessages, desktopnotify, slacknotify, soundnotify | | Observability | sessionlog, commandlog, costwatch, errornotify, spiral-detector, scratch-lifecycle | | Agent teams | taskgate, session-start, stop-review, stop-sync, filelock, announce-start, announce-stop, dm-inject, fleet-catchup, agent-rules-version-check, fleet-blockers-gate |
CLI Usage
Trash startup readiness
The bundled trash-readiness observer checks an installed Trash ready plan at
session startup. It reports a short ready or attention message. It does not
capture files, repair settings, retry failed requests, or authorize deletion.
Keep the separate trash-guard installed and enabled.
Claude and Codex use their native SessionStart event for startup, resume,
clear and fork. Compaction does not repeat this check. Configuration and native
execution are separate: successful setup leaves nativeAdoptionVerified:false
until the actual consumer has been exercised.
hooks safety startup registration-plan --target claude
hooks safety startup registration-apply --target claude --expected-plan-digest SHA256
hooks safety startup registration-plan --target codex
hooks safety startup registration-apply --target codex --expected-plan-digest SHA256
hooks safety startup trust-plan --target codex --codex-command /absolute/native/codex --cwd /absolute/project
hooks safety startup trust-apply --target codex --codex-command /absolute/native/codex --cwd /absolute/project --expected-plan-digest SHA256
hooks safety startup registration-plan --target sumi
hooks safety startup registration-apply --target sumi --expected-plan-digest SHA256Sumi runs the observer inside its own process from the bundled SDK, switched on
by Sumi's own experimental.trash_readiness setting, so there is no startup
command to register. For Sumi, the startup registration re-points the Hooks-owned
guard record, ~/.hasna/hooks/native/sumi-trash-guard.json, at this installed
package. Sumi's startup check verifies that record. The registration uses the same
read-only plan, the exact plan digest and a retained predecessor as the other
targets. Unlike hooks safety provision, it also moves a different record that
still verifies. It never writes Sumi's configuration.
After registration and trust, use Trash's station setup flow to produce a new ready plan. Native configuration changes must precede this plan because its digest binds the selected configuration. Bind that plan explicitly:
hooks safety startup bind --target codex --binding /absolute/binding.json --expected-binding-sha256 absent
hooks safety startup check --target codexThe full binding-file contract is in
docs/trash-readiness.md. It covers where each field
comes from, the compare-and-swap digest, the output and refusal codes, and how
to probe a result. The binding schema is hasna.hooks.trash-readiness/v1. It contains the exact
provider and home, runtime:{path,sha256}, trash:{root,version,cliSha256},
plan:{path,sha256}, and timeoutMs (1000–60000). The plan is the ready plan
that trash station plan --out wrote, and runtime is that plan's spec.runtime.
--expected-binding-sha256 is the SHA-256 of the installed binding file, or
absent. Use 45000 for a normal
whole-startup budget, configurable within those bounds. Existing bindings retain
their explicit budget until a guarded update. Claude and Codex registrations
allow 65 seconds for the observer's 60-second maximum plus runner overhead.
Authentication, station identity, source/hash checks and terminal timeout remain
mandatory; the separate removal guard's deadlines are unchanged. Paths are absolute; the
package and plan must be inside that home. The installed binding lives at
~/.hasna/hooks/native/trash-readiness/<provider>.json. Updating it requires its
current digest; the writer preserves an immutable predecessor, intent and
receipt. Response loss requires inspection before another explicit operation.
Generic Hooks registry authentication is unchanged.
The @hasna/hooks/native-readiness export also exposes the observer for harness
integrations as await runTrashReadiness(provider, { signal }). The observer
supervises only its newly created check process group, including nested clients,
and cleans it up on completion, cancellation or timeout. Selecting sumi
identifies that consumer; it does not register
a Sumi lifecycle event. A Sumi adapter must call the observer through its own
supported startup path and verify delivery separately. A running harness keeps
the SDK version it started with. A 45000 ms binding needs a consumer built with
0.11.4 or later, because older consumers cap timeoutMs at 15000 and report
binding_invalid.
hooks --helphooks installhooks listhooks searchhooks removehooks categorieshooks infohooks doctorhooks run
Compact Output
CLI commands default to compact, agent-friendly output. List and search commands show essential fields, cap terminal rows, and print hints for deeper inspection. Use detail flags when you need more context:
hooks list # compact, capped list
hooks list --all # show all rows
hooks list --verbose # include descriptions
hooks search git --limit 5 # cap result rows
hooks info gitguard # full metadata for one hook
hooks docs gitguard # README preview
hooks docs gitguard --verbose
hooks list --json # stable machine-readable full dataMCP tools follow the same gradual disclosure pattern: list/search/log/profile
tools return compact summaries by default, while explicit flags such as
compact:false, verbose:true, or a detail tool like hooks_info return full
records.
Recoverable deletion guards
hooks safety install trash-guard --target codex
# Or --target claude. Use --overwrite to update an existing registration.This installs a bundled, synchronous safety capability without requiring a Hooks
registry credential. Generic hooks run, registry commands, and custom hooks keep
their existing authentication requirements. The Trash guard also evaluates the
protected repository policy, so its handoff cannot silently permit deletion.
The installed command pins its absolute Bun runtime and complete worker bundle.
It verifies those bytes before execution and blocks startup failures, malformed
input/output, changed files and deadlines. Native execution has a ten-second
budget; its own watchdog refuses after six seconds. Updating Bun or Hooks requires
reinstalling the registration and reviewing the new native trust definition. Unsafe
installation paths are refused before settings are written. Codex also requires
trust through /hooks; registration alone does not prove interception.
When verification runs out of time (the six-second watchdog, the 2.5-second
worker deadline or the 1.5-second input deadline), the guard blocks the call with
Native safety guard refused (guard_timeout): …. The command did not run, and one
retry is safe. A timeout that repeats means the host is overloaded, or that the
registration is stale: a runtime that no longer matches its pin is never cached,
so it is hashed in full on every call and, under load, can time out before it is
refused. Check with hooks safety verify from an operator shell, report it, and do
not bypass the guard. Every other failure, including a runtime or worker that no
longer matches its pin, keeps Native safety guard refused: verification or
execution failed., and a policy refusal is the guard's own reason.
After a full hash of the pinned Bun runtime matches, the guard records the
runtime's exact stat key (device, inode, size, mtime, ctime and birth time) in an
owner-only entry under ~/.hasna/hooks/native/runtime-digests/, and later calls
skip the runtime hash while that key is unchanged. Any write to the runtime
changes its ctime, so it is hashed in full again. The cache is used only where
~/.hasna/hooks/native already exists, under directories that are not writable
by group or others. hooks safety registration-apply, hooks safety trust-apply
and the Sumi guard record create it; a stateless hooks safety install does not,
and the guard never creates it itself. Without it, every call hashes the runtime
in full, as before. The worker is still hashed on every call.
The Sumi guard record (~/.hasna/hooks/native/sumi-trash-guard.json) keeps the
previous template. Sumi reads that record with its own bundled copy of this SDK,
and every released copy accepts only that template; a record it cannot parse makes
it refuse every shell. Every Sumi writer here (hooks safety provision, hooks
install, hooks upgrade, the package lifecycle script, hooks safety startup
registration-apply --target sumi and hooks safety install trash-guard --target
sumi) therefore writes, and the reader accepts, only that template. Sumi keeps the
v1 messages and per-call runtime hash for now. This release's Sumi reader also
accepts only that template, so a Sumi built on it would refuse a new-template
record. Moving the record needs a later SDK whose Sumi reader accepts the new
template, and a Sumi build bundling that later SDK running everywhere, before any
release writes the new template for Sumi.
The same bundled entry also evaluates the signed-link-guard for every Bash
and Monitor command (Claude Code's Monitor tool runs a shell command and
streams its stdout to the model), under either capability name (trash-guard
or workspace-repos-guard): a gh read that would print composite GitHub
content (bodies, comments, reviews, check links and details, raw issue and
pull request objects) is refused before it runs with the reason
composite output withheld: signed-link shape; use bounded scalar projections per class disposition 799310.
Bounded scalar reads such as gh pr view <n> --json number,state,headRefOid,
gh pr checks <n> --json name,state,bucket and gh api … --jq .head.sha pass,
and so does every gh write, including a --body "$(cat <<'EOF' … EOF)"
body. A plain gh pr view <n> and a plain or --watch gh pr checks <n> are
refused, because they print the body and the check links; the guard's README
lists the scalar alternatives. See hooks/hook-signed-link-guard/README.md for
the full rule list and its limits. The guard is also a catalog hook
(hooks install signed-link-guard), and signed-link-output is an optional
Claude PostToolUse backstop that replaces signed-link URLs in tool output where
the running Claude Code supports output replacement.
Monitor commands get every check a Bash command gets. A deletion inside a
Monitor command is refused instead of rewritten into Trash, because the
rewrite is proven only for the Bash tool; run deletions with Bash. Claude
registrations therefore match
^(Bash|Monitor|apply_patch|ApplyPatch|functions\.apply_patch)$ (the
repository guard also matches Write, Edit, MultiEdit and
NotebookEdit). Re-registering with --overwrite or registration-apply
updates a registration that still has the matcher without Monitor in place,
and the Claude safety check reports that older matcher as not installed.
Because a registration pins the worker's digest, an installed registration
keeps running the previous worker until it is re-registered for the new
install; see "Rolling out a new native worker" below.
For the default global Claude settings path, a managed Skills installation
coordinates hook changes and discovery witnesses in one guarded update. Existing
drift or concurrent edits refuse installation before an uncoordinated write.
Standalone installation remains available only while Skills is absent. When the
file carries Skills' own hook user-prompt entries, or the Skills bridge
~/.claude/skills/skills-cli/SKILL.md exists, but no Skills agent policy
resolves, installing, removing and pruning refuse with skills_policy_unresolved,
list every location searched and write nothing; run Hooks with the environment
that selects the Skills data directory (for example HASNA_SKILLS_DIR). This
coordination includes project or custom paths resolving to that same file; it
does not cover separate project settings files.
At every Claude or Gemini settings path, a settings file that exists but cannot
be read or does not parse as a JSON object is never replaced: installing (also
through hooks_enable) refuses with settings_unreadable and writes nothing.
Repair the file or move it aside, then retry. A missing file is still created.
The MCP tools hooks_disable and hooks_enable use the same writer, and a
refusal is an MCP error carrying its code and message. hooks_disable removes
the hook's Claude registration, which is equivalent to hooks_remove for that
registration; the hook stays installed. For the safety guards trash-guard and
workspace-repos-guard that also removes their protection until the guard is
enabled or installed again. hooks_enable is equivalent to a non-overwrite
hooks_install: it installs a hook that was never installed, and does nothing
when the hook is already registered. It only registers hooks that hooks_install
can install, so after disabling trash-readiness, or a stale registration of a
hook that no longer exists, hooks_enable fails with Hook '<name>' not found.
No separate disabled state is stored, so a disable then enable is not an exact
round trip. The registration comes back with the default matcher and timeout,
at the end of its event's list, in a group of its own rather than one it shared
with other hooks, and once per event even where disable removed duplicates. A
--profile binding returns only if profile is passed to hooks_enable again,
and Mementos options set at the original install are not restored.
Supported controls cover shell rm rewrites through a verified @hasna/trash
guard, explicit unsupported delete commands, and whole-file apply_patch
deletions. They do not intercept arbitrary filesystem syscalls, application APIs,
or in-place edits. A missing or unauthenticated Trash client refuses the rewritten
operation; it never falls back to raw removal. Configure hosted Trash and verify a
disposable capture/restore before relying on recoverable deletion.
hooks safety inspect is a small, read-only installation check. It returns
compact JSON with the verified Trash path and version, or a fixed failure code
such as unsafe_executable, unsafe_directory, unsafe_manifest or
identity_failed, and exits nonzero on failure. It checks package identity,
ownership, permissions and a bounded credential-free protocol probe; it does not
contact the registry or Trash API. Use it after setup and package upgrades.
Repair unsafe installation permissions with a verified package installer, then
check again. This check alone does not prove hosted authentication, native hook
trust or a running agent's interception.
Planned safety registration
For repeatable global setup, use the owning installer through its plan/apply commands. Planning leaves native settings unchanged and returns a compact digest.
hooks safety registration-plan --target codex
hooks safety registration-apply --target codex --expected-plan-digest PLAN_SHA256
# The same two commands support --target claude.Both commands, and trust-plan/trust-apply below, take
--guard trash-guard|workspace-repos-guard (default trash-guard). Only the
selected guard's own entry is rendered and written; every other entry, a
disabled one included, keeps its exact bytes. A plan digest for one guard is
never accepted by an apply for the other. trash-guard is verified by having its
supervisor rewrite a synthetic removal into Trash; workspace-repos-guard, which
never rewrites, by having its supervisor refuse a synthetic removal below
~/.hasna/repos/clones with the guard's exact reason. Neither removal is run,
and neither path is created.
Apply checks the exact predecessor, retains an owner-only original and intent,
and reads back the result. An identical registration returns unchanged without
rewriting its settings. The planner honors CODEX_HOME and CLAUDE_CONFIG_DIR;
conflicting Hooks path overrides refuse. Missing native settings directories are
created only on apply. Disabled or asynchronous guards, unsafe paths, duplicate
JSON keys and numbers that cannot be preserved exactly refuse before a write.
Managed default Claude settings continue through Skills' coordinated writer.
For those, the plan also runs that writer's own read-only checks on the exact
change: it parses the Skills agent policy and plans the update without writing.
When the writer would refuse, the plan refuses as apply does, with
writer_refused and the same refusal: {code, message}, and returns no digest.
The SDK exports planNativeSafetyRegistration and
applyNativeSafetyRegistration. Both return nativeAdoptionVerified: false.
A changed or uncertain apply requires a fresh read-only plan and inspection of
its recorded operation; no automatic retry or direct overwrite is performed.
A writer refusal found while planning returns writer_refused with its cause as
refusal: {code, message}, from plan and apply alike; nothing is written or
journaled. A refusal later, before the write, returns apply_refused with its
cause and records refused.json beside the operation's intent; settings are
unchanged. Only a failure after the write may have started returns
apply_outcome_uncertain.
After registration, Codex trust uses its supported native configuration writer:
hooks safety trust-plan --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
hooks safety trust-apply --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex --expected-plan-digest PLAN_SHA256The exact definition is verified before trusting and enabling only that guard. Unrelated configuration is preserved, and an already trusted, enabled guard does not cause a write. A disabled native hooks feature is a refusal, not an implicit feature change. Neither registration nor trust proves that an existing agent has adopted the change.
For Claude, hooks safety verify --target claude --cwd /path/to/project checks
the selected settings and bundled supervisor. Its result explicitly leaves
runtime-policy verification, native discovery and native adoption false;
MDM/server policy, workspace trust and launch flags require native acceptance.
Rolling out a new native worker
Every native registration pins the SHA-256 of bin/native-safety-entry.js, so
a release that changes the worker reaches a harness only when that harness is
re-registered against the new install. Run each step with the new version's
CLI (hooks --version), after installing that version:
# Claude Code
hooks safety registration-plan --target claude
hooks safety registration-apply --target claude --expected-plan-digest PLAN_SHA256
# Only where the repository guard is registered under its own name:
hooks safety registration-plan --target claude --guard workspace-repos-guard
hooks safety registration-apply --target claude --guard workspace-repos-guard --expected-plan-digest PLAN_SHA256
# Codex (a changed definition runs only after it is trusted again)
hooks safety registration-plan --target codex
hooks safety registration-apply --target codex --expected-plan-digest PLAN_SHA256
hooks safety trust-plan --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
hooks safety trust-apply --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex --expected-plan-digest PLAN_SHA256
# Where Codex runs the repository guard under its own name, repeat the four
# Codex commands with --guard workspace-repos-guard.
# Sumi
hooks safety verify --target sumi # prints the current binding's sha256
hooks safety install trash-guard --target sumi --expected-binding-sha256 CURRENT_SHA256
hooks safety verify --target sumi --executionHooks registered as hooks run <hook> do not go through the native worker, so
re-registration does not cover them. When a release changes the script of such
a hook, run hooks trust <hook> with the new CLI on every station after
switching PATH to it, as "Trust model" under "Custom and remote hooks"
describes. Until then, wherever an old pin exists, it refuses the new script
and that hook exits 1 on every call.
hooks safety provision --target sumi, which the package's lifecycle script
also runs, keeps a binding that still verifies, so it does not move Sumi to a
worker installed in a different tree; the explicit install above does. That
install refuses --overwrite with input_invalid: the expected digest is its
upgrade guard. A binding that no longer verifies (its worker was rewritten)
cannot report its digest; hooks safety provision --target sumi re-registers
that one and keeps the previous record beside it. Where
hooks safety startup registration-plan and registration-apply accept
--target sumi (hasna/apps#2932), that registration re-points the same record
through a read-only plan and an exact plan digest, and it supersedes the install
form above.
A running Sumi service keeps the readiness code bundled into its own build, so Sumi's startup readiness warning clears only once the service runs a Sumi build of 0.2.45 or later. Restarting the service is an owner decision and is not part of this rollout.
Installing over the running version is a half-registration hazard.
bun add -g @hasna/hooks@<version> (or npm install -g) rewrites the worker
in place in the global package tree. Every registration still pinned to the
old digest then refuses every shell and patch command in its harness, since
the supervisor fails closed on changed bytes, until it is re-registered; the
lifecycle script re-provisions only Sumi. Until the last registration is
applied, some harnesses run the new guard and the others refuse everything.
Install each version into its own tree (the fleet's
~/.hasna/hooks/runtime/<version>-copyfile layout) and register from there,
so every old registration keeps verifying its own tree until it is moved. If
an in-place global upgrade cannot be avoided, re-register all three targets
straight after it.
The supervisor passes the worker to the runtime as one --eval argument, and
Linux limits a single argument to 131,072 bytes (32 pages of 4 KiB). The
worker is therefore built minified, and the package validation refuses a
worker at or above that size.
Known limits of the native entry:
- The signed-link guard judges command text: interpreters and script files
that run
ghthemselves, shell aliases and functions from earlier commands,ghextensions, direct HTTP clients such ascurlandGH_DEBUGset in the harness environment are outside it (details in the guard's README). - Codex's input into a running unified exec session (
write_stdin) has not been verified to pass through PreToolUse. - The repository guard reads a here-document body as data only when every
command word of the command is a data command such as
cat,git commitorgh pr(the list is in the guard's README). With any other command word it reads the body line by line, so prose in such a body that mentions a delete command can still be refused inside a protected checkout.
Codex safety readiness
hooks safety verify --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codexThis bounded, read-only check starts its own fresh Codex app-server and asks the native consumer for the selected project's hook definitions and feature state. It requires exactly one enabled, trusted, synchronous bundled Trash guard, checks its installed supervisor by classifying data, and verifies that the native definition stayed unchanged. It returns compact JSON and a nonzero exit on missing, disabled, untrusted, changed or unsafe state. No deletion is executed.
The command accepts a native Linux ELF or macOS Mach-O executable for the host
architecture (arm64 or x64); script launchers are refused before execution. When
Codex is installed through npm, pass the platform package's native binary rather
than its JavaScript launcher. Executable ownership, permissions, ancestry and
bytes are checked. Supported protocol versions are 0.153.0, 0.154.0,
0.154.0-alpha.6.1, 0.155.0, 0.155.1, 0.156.1, 0.157.0, 0.157.1, 0.158.0, 0.159.0, 0.159.2,
0.159.3, 0.160.0, 0.160.1, 0.161.0, 0.162.0, 0.162.1 and 0.163.0;
other versions refuse pending compatibility validation. Updating Codex can
therefore require a compatible Hooks release before native safety verification
and trust provisioning succeed. Keep existing hook definitions and trust intact
when this check reports native_unsupported.
The version probe has a five-second forced timeout and native discovery a
fifteen-second lifetime budget. Neither step restarts an existing agent.
Both run the native with an explicit environment allowlist: PATH, USER,
LOGNAME, LANG, LC_*, TMPDIR, the XDG base directories and Codex's own
CODEX_* variables, with HOME, NO_COLOR and CODEX_HOME set by Hooks.
Dynamic-loader (LD_*, DYLD_*) and runtime-hook (NODE_*, BUN_*,
BASH_ENV, ENV) variables never reach it, because only the executable's
bytes are verified.
Success reports nativeDiscoveryVerified: true, guardVerified: true and
nativeAdoptionVerified: false. It does not enroll trust, change configuration,
authenticate Trash, verify MCP or Instructions, or prove interception by an
already-running session. Complete those separate setup checks and a fresh native
interception proof before declaring station support. Use this small readiness
check after installation, meaningful upgrades and during fresh-agent startup;
never substitute repeated deletion smoke tests for a startup check.
Optional Mementos prompt context
hooks install mementos-context --target codex registers a native prompt hook.
Enable it in the calling shell with HOOKS_MEMENTOS_ENABLED=1; set
HOOKS_MEMENTOS_PROJECT to an existing Mementos project. Provider judgments are
separately opt-in. The hook returns bounded, quoted memory data and continues
on errors. See setup, scope and provider options.
Codex requires review and trust of the exact registration through /hooks.
Claude Code and Codewith also support this hook through their explicit targets.
Codewith-native hooks (deprecated)
Codewith is deprecated. These hooks remain for existing Codewith setups; do not choose Codewith for new work.
@hasna/hooks includes unprefixed Codewith-native hook names:
session-start—SessionStartdigest and additional context.prompt-guard—UserPromptSubmitguard for pasted fake policy/freeze/run-this-now content.pre-bash—PreToolUseBash gate for staged secrets scans, scoped destructive-operation blocks, and risky-op comms checks.worktree-guard—PreToolUseguard for managed repos worktree boundaries and file-tool-like payloads touching protected Hasna scopes.stop-sync—Stopturn-end heartbeat/evidence best effort.knowledge-context— deterministic Knowledge context packs forSessionStart,UserPromptSubmit, andSubagentStart.
For Codewith, the installer is renderer-safe by default: it emits a TOML
fragment instead of mutating managed ~/.codewith/config.toml.
hooks install session-start prompt-guard pre-bash worktree-guard stop-sync knowledge-context --target codewithThe scoped destructive-operation guard does not block every cleanup command. It
blocks resolved shell/file-tool targets that threaten / or a system root
(/usr, /etc, /bin, /lib, /var, /boot, /home, /Users, and the
other FHS and macOS equivalents), ~/.hasna, configured workspace roots, Hasna
division/scope roots, or active repo/worktree roots, including recursive rm,
rsync --delete, destructive find, and destructive git clean / git reset
--hard forms.
It also blocks by shape: a destructive target containing a command
substitution or variable expansion immediately followed by / is checked as the
shell would render it if that expansion returned empty, so
rm -rf "$(anything)"/* and rm -rf "$VAR"/* are refused whatever the
expansion is. Wrapped forms (bash -c, su -c, eval, ssh host '…') are
unwrapped first. See hooks/pre-bash/README.md for
the full rules, the deliberate exemptions (${VAR:?}, bare "$(cmd)" with no
trailing separator), and the recommended safe form.
Apply that fragment through configs or the managed config renderer. A
direct write path exists only for explicit local/test use:
hooks install knowledge-context --target codewith --apply-codewith --codewith-config /tmp/codewith-config.tomlCustom and remote hooks
A hook is defined by a manifest — { name, version, description, events, script, args?, timeout_ms? } — where script is a relative path or inline content. Hooks come from three sources: the bundled registry, a user custom directory, or a remote registry.
timeout_ms is an optional positive integer (milliseconds). Omit it for no timeout. A timeout of 0 is never a real value: a manifest or MCP call that passes 0 (or a negative value) is rejected, and the SDK treats a non-positive option as not provided — falling back to the manifest value, or to no timeout when the manifest has none. So timeout_ms: 0 can never mean "kill immediately"; either a positive bound applies or there is no bound at all.
Install custom hooks from a local directory, a git URL, or a manifest URL:
hooks install ./my-hook # directory with manifest.json
hooks install [email protected]:org/hook-repo.git
hooks install https://example.com/hooks/my-hook/manifest.jsonCustom hooks land in ~/.hasna/hooks/hooks/<name>/. A custom hook with the same name as a bundled hook takes precedence (visible in hooks info <name>).
Trust model. Every hook script is pinned by sha256 in hooks.lock in the hooks data directory (~/.hasna/hooks/hooks.lock unless the data directory resolves elsewhere; HASNA_HOOKS_LOCK_PATH, or its alias HOOKS_LOCK_PATH, overrides the lock path) and, in local mode, in the SQLite hooks table, which is checked first. hooks run verifies the script hash before executing; if the script changed, the run is refused and exits 1:
hooks trust <name> # re-pin the current script content
hooks update # re-register hooks and refresh pinsA hook's first run writes its pin, and so do hooks sync (in local mode it pins every bundled hook without running any of them), hooks update, hooks install <name>@<version>, hooks update <name>@<version> and hooks trust. A station can therefore hold a pin for a hook that has never run there. After any upgrade that changes the script of a hook registered as hooks run <hook>, run hooks trust <hook> with the new CLI on every station. Run it as the same user and with the same hooks environment that the harness's hooks run sees: the local opt-in or registry credential, and any data directory, DB or lock path override. Otherwise it can write a pin that hooks run does not read. Where the hook is not pinned yet, it pins the same sha256 that the first run would have pinned, so it is harmless there.
Hosted publication and station sync
Publishing, selecting a version, downloading it and registering a native event
are separate operations. The hosted registry stores immutable artifacts and
append-only selection revisions. Clients use /v1; the default authority is
https://api.hasna.com/hooks/v1.
hooks publish ./my-hook/manifest.json --dry-run --json
hooks publish ./my-hook/manifest.json --json
hooks selection coding --json
hooks selection coding --file ./reviewed-selection.json --json
hooks sync --selection-profile coding --expected-revision 1 --dry-run --json
hooks sync --selection-profile coding --expected-revision 1 --jsonPublication bundles a file entry and its dependency closure before hashing it.
Re-publishing identical bytes is idempotent; changing an existing name/version
is refused. Secrets must be references consumed at execution, never manifest
environment values. A selection request contains expected_revision (zero for
a new profile) and a complete hooks map of exact version and digest pins;
preserve existing selections when preparing a replacement. Read back the
returned revision before syncing it.
Sync verifies the selected artifacts before updating execution files and pins. Unselected hooks remain untouched. A failed remote sync cannot authorize an unpinned executable through first-use trust. A successful sync does not prove that a native harness has registered or executed the hook.
The bundled stop-review hook uses a pinned Instructions source and a Secrets reference for TypeSafe Jev. Its native adapters keep cancellation, current-turn checks and continuation limits in the harness. Registration alone is not proof of adoption by a running agent.
For a compatible OpenCode build, the package exports the self-contained
@hasna/hooks/opencode-stop-plugin transport. Plan registration from an installed,
retained runtime, then apply with the exact configuration digest from that plan:
hooks install stop-review --target opencode --dry-run --json
hooks install stop-review --target opencode --expected-config-sha256 <planned-digest-or-absent> --json
hooks install stop-review --target opencode --verify --jsonThe installer preserves and reads back the previous configuration, retains
unrelated settings and plugins, and refuses JSONC or conflicting entries.
It uses the global OpenCode configuration by default; --project selects
the current project's configuration. Verify any additional OpenCode configuration
overrides on the intended consumer. A stock OpenCode build has no guarded stop
capability: this plugin refuses registration there. Disk verification reports
nativeAdoptionVerified: false until a real native invocation proves adoption.
For Sumi 0.2.69 or a compatible native guarded-stop build, register the public
@hasna/hooks/sumi-stop-plugin factory in a dedicated project first:
hooks install stop-review --target sumi --project --dry-run --json
hooks install stop-review --target sumi --project --expected-config-sha256 <planned-digest-or-absent> --json
hooks install stop-review --target sumi --project --verify --jsonThis writes only .sumi/plugins/hasna-hooks-stop-review.js in the current
project directory, which must be the fresh consumer’s working directory. It refuses
project registration when Sumi’s project-discovery disable flags are enabled or
the working directory is Sumi’s global config root. Global scope follows an absolute
SUMI_CONFIG_DIR, then $XDG_CONFIG_HOME/sumi, then $SUMI_HOME/config.
Without an override it follows Sumi's canonical native config home
and adopted ~/.config/sumi aliases, preserving a live legacy home. It does not
perform Sumi's home migration; independent canonical/legacy directories are refused
until Sumi reconciles them. Sumi discovers the default-export plugin itself; the installer does not
add a file entry to sumi.json. It preserves unrelated configuration/plugins
and the exact previous managed wrapper, refuses conflicting or unsafe paths,
and pins the retained public transport, Hooks CLI and runtime by SHA-256.
An identical registration is a no-op; updates require the current wrapper digest.
Unknown targets are refused before any Claude registration can occur.
Sumi watches native plugin directories. Applying a wrapper can affect a running
consumer of that project or global directory: use a fresh dedicated project for
a canary and do not change a shared directory until its consumers are compatible.
The factory refuses consumers without the parent-owned hooks.stop-review.v1
capability. Registration does not sync or trust a hook, enable the runtime policy,
change Instructions/Secrets references, or start/restart Sumi. Those remain
separate supported steps. Verification reports nativeAdoptionVerified: false;
confirm exactly one active local.hasna-hooks-stop-review with the expected local
wrapper path in native /api/plugin, then prove a guarded stop in a fresh session.
Registry server. hooks serve exposes the configured registry over HTTP:
hooks migrate --registry-only --check --json
hooks migrate --registry-only --json
hooks serve --port 39428
# GET /health, GET /ready, GET /v1/catalog, GET /v1/hooks/:name/:version,
# PUT /v1/hooks, GET/PUT /v1/selections/:profile, GET /v1/lockHosted PostgreSQL storage requires the configured database and signing key.
Signed keys are checked against their application, scopes and lifecycle state:
reads require hooks:read, publication and selection require hooks:write.
Missing hosted configuration fails closed, including legacy /api/v1 aliases.
Static-key/local serving requires an explicit local opt-in; it is never a
fallback for a missing signer or database. The registry-only migration adds its
own tables without running the older event-store migration.
Registry selection (fail closed, strict pair). The remote-registry authority and its credential resolve through the ONE @hasna/contracts client chain (2026-09-04 adoption, hasna/apps#1720), fresh on every call, as a STRICT pair: a URL without a key is a refusal, never half-open progress, and a key alone is a complete configuration (it resolves the fleet gateway https://api.hasna.com/hooks). The chain, in order: an explicit --api-key/--profile argument; HASNA_HOOKS_API_KEY_OVERRIDE / HASNA_PROFILE / HASNA_HOOKS_API_KEY_REF; the macOS Keychain items hasna.credentials.hooks.api-key / .api-url; ~/.hasna/hooks/config/credentials (owner-only 0400/0600); then HASNA_HOOKS_API_URL / HASNA_HOOKS_API_KEY (the unprefixed HOOKS_* spellings remain only as the resolver's silent alias fallback). The retired locations (~/.hasna/fleet-env, ~/.hasna/cloud, ~/.config/hasna, $XDG_CONFIG_HOME) and the retired ~/.hasna/hooks/config.json key store are never read, and no *_MODE switch exists. Without a resolved credential, the CLI fails closed: registry commands refuse to run rather than silently serving the bundled catalog and local SQLite store, and they name every tier they consulted in the error. Local mode (bundled registry + local store) is an explicit opt-in:
HASNA_HOOKS_LOCAL=1 hooks list # canonical local opt-in
HOOKS_LOCAL=1 hooks list # accepted aliasLocal mode is answered BEFORE the resolver runs (so an unhosted run touches neither the Keychain nor the credential file) and says so on stderr, once per process.
Hosted is a route, not just admission (owner ruling 2026-09-07). Once a registry credential resolves — the env pair, the Keychain item, or the credentials file — the on-box SQLite store is refused for the whole process: hooks log *, hooks storage *, the MCP hooks_log_* / storage_* / send_feedback tools and the hook-event writer answer REMOTE_COMMAND_UNSUPPORTED (naming the opt-in) instead of reading or creating ~/.hasna/hooks/hooks.db. Pins and trust keep working on the hosted route through hooks.lock alone; the SQLite hooks table is a local-mode mirror.
hooks run decides its route like every other client verb. Under the explicit opt-in it executes the hook and records the event in the on-box store (the child hook sees HOOKS_LOCAL); on the hosted route it executes the hook but refuses the event write loudly (one stderr line — the hosted registry has no event route); with nothing configured it prints a REMOTE_API_CONFIG_MISSING error naming the credential tiers and the opt-in, creates nothing, and exits non-zero. For a bundled hook whose only event is PreToolUse (including a credential that is present but refused) it exits 2 and adds a "Blocked:" line, so the agent refuses the tool call instead of running it; every other hook exits 1, which agents treat as a non-blocking error. The same exit 2 applies whenever hooks run cannot run such a guard and so decides nothing: a missing script, an unreadable script or trust record (such as a malformed hooks.lock), a script changed since it was trusted, invalid run options (including an unknown option or a missing option value), an invalid native target, or a failure to start such as a read-only install refusing the temporary copy. The hook- alias of a guard's name counts as the guard; a custom hook installed under a guard's name takes precedence and keeps exit 1. Bundled hooks have no hooks run timeout, so a hung guard is bounded by the agent's own hook timeout. Before 0.12.21 an unconfigured PreToolUse guard also exited 1, and the guarded command ran. Agents that wire hooks run <name> into their settings on an unconfigured machine therefore need either a registry credential or HASNA_HOOKS_LOCAL=1 in the hook's environment.
Surfaces that never open the client store and are server/operator-only by design need neither setting: serve (hooks-serve), cf, migrate, init, profile-export/profile-import, channels, events, and --help/--version. hooks mcp and the hooks-mcp bin decide their authority themselves before any transport connects (below).
MCP server (hooks-mcp). One package, four surfaces: the stdio MCP server is the hooks-mcp bin (34 tools).
bun install -g @hasna/hooks # then "command": "hooks-mcp" in your agent's mcpServers
bunx -p @hasna/hooks hooks-mcp # no global install
hooks mcp --stdio # same server through the CLI; --sse / --http for the shared transportshooks-mcp fails closed at startup: with nothing configured it prints one REMOTE_API_* line naming the credential tiers and HASNA_HOOKS_LOCAL=1, exits 1 before answering initialize, and creates no file. Under a resolved credential it serves the catalog/install tools and the local-only tools refuse with REMOTE_COMMAND_UNSUPPORTED; under the opt-in it serves the on-box store and prints hooks: LOCAL mode — … on stderr once.
SDK (@hasna/hooks/sdk). The importable surface is a hosted registry client that resolves its credential through the same chain and throws REMOTE_API_* when nothing resolves — it never returns a client bound to a local file, and it refuses HASNA_HOOKS_LOCAL=1 by name (the on-box store is the CLI's). The bundle is self-contained: node builtins only, no bun:sqlite.
import { createHooksClient } from "@hasna/hooks/sdk";
const registry = createHooksClient(); // throws REMOTE_API_CONFIG_MISSING with nothing configured
const catalog = await registry.catalog(); // GET <origin>/v1/catalog
const lock = await registry.lock(); // GET <origin>/v1/lock
const artifact = await registry.artifact("gitguard", lock.hooks.gitguard.version);
const selected = await registry.selection("coding");hooks init --cloudflare --api-url https://registry.example.com --api-key <vault-key-name>
HASNA_HOOKS_LOCAL=1 hooks sync # local workflow: bundled catalog into the local store
HASNA_HOOKS_LOCAL=1 hooks sync --dry-run # print the plan without changing anythinghooks init --cloudflare no longer writes a config file — config.json is retired (hasna/apps#1720). It prints the exact configuration to apply: set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY_REF (the vault key NAME, never the value) in the environment, or store the Keychain items / credentials file:
export HASNA_HOOKS_API_URL=https://registry.example.com
export HASNA_HOOKS_API_KEY_REF=<vault-key-name> # resolved through the vault at request time
secrets exec <vault-key-name> --as HASNA_HOOKS_API_KEY -- hooks serveThe owner-only ~/.hasna/hooks/config/credentials file also accepts a reference:
HASNA_HOOKS_API_URL=https://registry.example.com
HASNA_HOOKS_API_KEY_REF=your-team/hooks/live/api-keyKeep this file at mode 0400 or 0600, and configure Secrets through its normal independent credential resolver. Hooks resolves the selected reference through the Secrets SDK for each registry request and each server publish-key check. References retain their existing precedence: a locked Keychain remains a refusal ahead of a file reference, and a missing reference never falls through to a stale literal key. A file cannot contain both a literal key and a reference.
hooks list displays catalog metadata and does not prove that the remote registry is reachable. Use hooks sync --dry-run to exercise registry reads without changing installed hooks. SDK consumers use createHooksClient(); callers working directly with transport metadata must await resolveHooksRequestAuthority() before sending a request. The server uses resolveHooksServePublishKeyAsync() for references.
Cloudflare provisioning. hooks cf deploy creates the D1 database and R2 bucket via the Cloudflare API, then prints the exact wrangler commands for the worker upload (the worker needs the workerd target, which only wrangler can bundle):
export CF_API_TOKEN=... # resolve from the vault, never paste the value
hooks cf deploy --account-id <id> --dry-run # plan first
hooks cf deploy --account-id <id>The worker (src/cf/worker.ts) implements the same API routes against D1 + R2, with artifacts at hook_artifacts/<name>/<version>.json. See src/cf/wrangler.toml.example.
Storage
When local mode is explicitly selected, Hooks stores data in ~/.hasna/hooks/
and uses SQLite for hook event history. The package owns its database schema and
migrations; it does not depend on the deprecated shared runtime or its CLI.
The repo includes its own PostgreSQL migration definitions for the optional
hooks storage push|pull|sync commands. Use the hooks log commands to inspect
local hook event data.
hooks storage status --json
HASNA_HOOKS_DATABASE_URL=postgres://... hooks storage push --tables hook_events,feedback --json
hooks storage pull --json
hooks storage sync --jsonConfigure database storage with HASNA_HOOKS_DATABASE_URL or fallback
HOOKS_DATABASE_URL.
Storage backend
Hooks storage has one setting with two values: which data backend, not where anything is deployed.
| HASNA_HOOKS_STORAGE_BACKEND (fallback HOOKS_STORAGE_BACKEND) | meaning |
| --- | --- |
| sqlite | the on-box SQLite file in ~/.hasna/hooks/ (default) |
| postgresql | the PostgreSQL database named by HASNA_HOOKS_DATABASE_URL |
Leave it unset and the backend is inferred exactly as before: postgresql when a
database URL is configured, sqlite otherwise. An unrecognised value is an
error, not a silent fall back to SQLite.
The former deployment-mode variables HASNA_HOOKS_STORAGE_MODE and
HOOKS_STORAGE_MODE, and their local / hybrid / remote / self-hosted /
cloud values, are retired. They are not read; setting one raises an error
naming the replacement variable and the backend to use (local became sqlite,
everything else became postgresql). Deployment location was never a property of
the data layer, so it is no longer expressed as one.
Environment variables
| Variable | Purpose |
| --- | --- |
| HASNA_HOOKS_API_URL | Registry URL (strict pair — requires a credential with it). Fallback aliases are not read; the unprefixed HOOKS_API_URL survives only as the resolver's silent alias. HASNA_HOOKS_REGISTRY_URL / HOOKS_REGISTRY_URL are retired. |
| HASNA_HOOKS_API_KEY | Registry key, resolved through the @hasna/contracts chain (below disk, above nothing else). Unprefixed HOOKS_API_KEY is the silent alias. |
| HASNA_HOOKS_API_KEY_OVERRIDE | Deliberate per-run key override (tier 1). |
| HASNA_HOOKS_API_KEY_REF | Vault ITEM KEY name; resolved through @hasna/secrets at request time. Never a value. |
| HASNA_PROFILE | Selects which identity profile the chain reads. |
| HASNA_HOOKS_LOCAL / HOOKS_LOCAL | Explicit opt-in to local mode (bundled registry + on-box SQLite store); answered before the resolver runs and printed on stderr. The ONLY way any client surface (hooks, hooks-mcp, hooks run) opens hooks.db; a configured registry credential outranks it. |
| HASNA_STATION | Keychain account when reading hasna.credentials.hooks.*. |
| HASNA_HOME / HASNA_CONFIG_HOME | Move the disk credential root (<…>/hooks/config/credentials). ~/.hasna/fleet-env, ~/.hasna/cloud, ~/.config/hasna and $XDG_CONFIG_HOME are never read. |
| HASNA_HOOKS_DATA_DIR / HOOKS_DATA_DIR, HASNA_HOOKS_HOME / HOOKS_HOME, HASNA_HOOKS_DB_PATH / HOOKS_DB_PATH, HASNA_HOOKS_LOCK_PATH / HOOKS_LOCK_PATH | Local store locations (data root, DB, lock). |
| HASNA_HOOKS_DATABASE_URL / HOOKS_DATABASE_URL, HASNA_HOOKS_STORAGE_BACKEND / HOOKS_STORAGE_BACKEND | Optional PostgreSQL data backend (see Storage). HASNA_HOOKS_STORAGE_MODE / HOOKS_STORAGE_MODE are retired and raise an error. |
Runtime model
This package is an npm CLI (hooks), a stdio MCP server (hooks-mcp), a local
registry server (hooks-serve) and an importable hosted client (@hasna/hooks/sdk).
Installing and running hooks needs nothing deployed anywhere, but the on-box SQLite
store is never a silent default: every client surface either resolves a registry
credential (hosted route — no local store is opened) or is told HASNA_HOOKS_LOCAL=1
(local mode — announced on stderr); with neither it exits non-zero and creates nothing.
Required Sumi safety binding
hooks safety install trash-guard --target sumi registers the bundled composite
guard for Sumi's required native adapter. hooks safety verify --target sumi
checks the exact saved supervisor by classifying a synthetic removal without
creating a file or executing the rewritten command. Both
commands are credential-free and return compact JSON. Verification explicitly
reports nativeAdoptionVerified: false: a fresh native Sumi tool invocation is
still required to prove that the installed harness uses this binding.
The binding is provisioned from the package's own install, upgrade and safety surfaces, so a rollout cannot leave Sumi's fail-closed check unbound:
- the package lifecycle script (
postinstall) provisions it for a packaged install — a copy inside anode_modulesdirectory, never a source checkout; hooks installandhooks upgradeapply the same step after a successful hook install or package upgrade, and report it assumiBindingin--json;hooks safety provisionruns it on demand and is credential-free.
Every surface is idempotent and shares one policy: a missing binding is created,
a byte-identical one is written nowhere, a different binding that still verifies
is left intact, and one whose pinned runtime or worker no longer verifies (an
upgrade rewrote the worker, or the runtime prefix was pruned) is re-registered
with the previous record preserved beside it as <path>.before-<sha256>. An
unreadable or unrecognized record is refused rather than overwritten, and
provisioning never fails the calling command — it reports a stable code,
because an unavailable Trash guard must not break a hook install. bun blocks
lifecycle scripts for untrusted dependencies and --ignore-scripts skips them,
so the CLI surfaces carry the same policy wherever the lifecycle script does not
run. A harness rollout that installs this package with scripts disabled must run
one of those surfaces; that is the harness owner's half of this contract.
The binding pins the absolute paths and sha256 of the Bun runtime and the worker
bundle as they exist when it is registered, and every evaluation re-verifies
those exact bytes. A worker inside a tree a package upgrade rewrites in place —
bun's global install root, its cache, or an isolated node_modules/.bun/… store —
is therefore only valid until that tree is upgraded; the next rewrite refuses
every command until provisioning runs again. Provisioning still closes the
immediate gap (an unbound station has no shell at all) but reports it: every
result carries durable, and a false value prints the repair step. Provision
rollouts from a stable, versioned install tree (for example the fleet's
~/.hasna/hooks/runtime/<version>-copyfile layout) and re-run
hooks safety provision after upgrading a tree the binding points at.
The owner-only ~/.hasna/hooks/native/sumi-trash-guard.json pins the supervisor's
runtime, worker, home and PATH. Owner and mode are part of the trust decision: a
record that is not owned by the current user, or that group or others can read or
write, is refused with the failing property named — never trusted, adopted or
silently replaced — because a rewritable binding can point the supervisor at a
different worker. Identical setup is a no-op. An upgrade requires
--expected-binding-sha256 <current-digest> and preserves the previous record.
An interrupted lock or unexpected current record requires reconciliation; setup
does not overwrite it. This file is native configuration, not a Hooks app store.
Setup checks the supervisor's bound PATH before registration; verification checks
the same readback digest before and after the probe. Package-manager bin file
symlinks are supported, but symlinked or writable PATH directory ancestry refuses
instead of hiding an unsafe parent through realpath resolution. The published
Trash script's Bun interpreter and all preceding PATH directories are checked
before its identity probe runs; unsafe or unsupported interpreter resolution
reports unsafe_interpreter without executing it.
Native adapters import @hasna/hooks/native-safety and pass the final shell/patch
operation to evaluateNativeSafetyForExecution, with cancellation and an explicit
agent label. A rewrite returns the complete command, shell, resolved cwd and
environment. The adapter must use those fields together, disable environment
inheritance, and prevent later hooks from changing them. Every rewritten removal
uses the verified absolute Bun runtime with a clean environment and project
configuration, env files, macros and installation disabled. Deletions combined
with shell environment changes are refused; run those removals separately.
Use hooks safety verify --target sumi --execution for a small, read-only check
that the saved worker supports this execution contract. A legacy worker that only
classifies commands cannot pass it. The compatible evaluateNativeSafety API and
verification without --execution classify commands only; their success does not
prove execution compatibility. Neither check runs the returned deletion, creates
a test file, authenticates Trash or proves native adoption.
The bounded agent label can identify the Sumi session in Trash's existing name field; it does not invent unsupported harness/session fields. Missing or unhealthy bindings reject the call. This covers supported shell removal and whole-file patch deletion, not arbitrary filesystem writes or plugin executor replacements. Installing the record alone does not activate a harness.
Data Directory
Data is stored in ~/.hasna/hooks/.
License
Apache-2.0 -- see LICENSE
