npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hasna/hooks

v0.13.0

Published

Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command

Readme

@hasna/hooks

Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command

npm License

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_command rather than Bash), so do not rely on them there yet. Codewith targets remain for existing setups, but Codewith is deprecated.

  • Install targets. --target also accepts qwen, muse, kimi, pi and cursor, each writing that harness's own config with an entry that calls hooks run <hook> --target <name>. They are not interchangeable: Muse hands over the final message as a quoted JSON string, which hooks run --target muse parses once; Kimi's stop payload carries no assistant text and Kimi blocks with exit 2 and stderr; Pi runs hooks through the hasna-pi-hooks bridge and receives no assistant text; Cursor's stop hook can neither block the turn nor carry the assistant text. A hook whose manifest sets requires_final_message or requires_block is refused at install on a target that cannot honour it, with the measured reason, instead of being installed to do nothing. opencode and sumi have no generic install path and are refused by name; their stop review is registered through hooks 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 rm to a recoverable trash store instead of deleting (trash-guard). It needs the trash CLI from @hasna/trash on PATH; without it, trash-guard refuses the rm;
    • 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. --target defaults to codex, so name the agent.

  • Interfaces. The hooks CLI, 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 nothing

Starting 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.js for hooks;
  • the same with bin/hooks-mcp.js or bin/serve.js for the others;
  • <package> is $(npm root -g)/@hasna/hooks for 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 SHA256

Sumi 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 codex

The 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 --help
  • hooks install
  • hooks list
  • hooks search
  • hooks remove
  • hooks categories
  • hooks info
  • hooks doctor
  • hooks 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 data

MCP 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_SHA256

The 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 --execution

Hooks 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 gh themselves, shell aliases and functions from earlier commands, gh extensions, direct HTTP clients such as curl and GH_DEBUG set 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 commit or gh 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/codex

This 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 — SessionStart digest and additional context.
  • prompt-guard — UserPromptSubmit guard for pasted fake policy/freeze/run-this-now content.
  • pre-bash — PreToolUse Bash gate for staged secrets scans, scoped destructive-operation blocks, and risky-op comms checks.
  • worktree-guard — PreToolUse guard for managed repos worktree boundaries and file-tool-like payloads touching protected Hasna scopes.
  • stop-sync — Stop turn-end heartbeat/evidence best effort.
  • knowledge-context — deterministic Knowledge context packs for SessionStart, UserPromptSubmit, and SubagentStart.

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 codewith

The 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.toml

Custom 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.json

Custom 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 pins

A 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 --json

Publication 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 --json

The 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 --json

This 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/lock

Hosted 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 alias

Local 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 transports

hooks-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 anything

hooks 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 serve

The 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-key

Keep 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 --json

Configure 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 a node_modules directory, never a source checkout;
  • hooks install and hooks upgrade apply the same step after a successful hook install or package upgrade, and report it as sumiBinding in --json;
  • hooks safety provision runs 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