A rules file like CLAUDE.md, AGENTS.md or a Cursor rule is text the model reads. It can follow it, forget it, or decide it doesn't apply. A hook is different: it is a command the client itself runs at a fixed point, such as before a tool call or when the agent tries to stop. The model doesn't get a vote.

That makes hooks the right place for a small number of hard checks. This page shows working hook configs for Claude Code, Codex and Cursor, using the hooks that skylos agent install-hooks writes. You can copy the configs and the ideas even if you write your own hook scripts instead.

Everything below was checked against Skylos 4.47.1 on 9 Oct 2026.


Install

pip install skylos
cd your-project

skylos agent install-hooks              # Claude Code, this project: .claude/settings.json
skylos agent install-hooks --codex      # Codex: .codex/hooks.json
skylos agent install-hooks --cursor     # Cursor: .cursor/hooks.json
skylos agent install-hooks --user       # Claude Code, every project: ~/.claude/settings.json
skylos agent install-hooks --dry-run    # print the merged config, write nothing

The installer merges its entries into the existing file. Your other hooks and settings stay, and running it twice changes nothing. If the file isn't valid JSON, it stops and leaves the file alone.

After installing:

  • Claude Code loads the hooks in the next session. Run /hooks to see them.
  • Codex skips new hooks until you trust them. Open /hooks in Codex and trust the Skylos entries.
  • Cursor reloads hooks.json by itself.

No account, API key or Docker is needed.


The four hooks

Skylos hookClaude Code eventCodex eventCursor eventWhat it does
post-editPostToolUse on Edit|Write|MultiEditPostToolUse on apply_patch|Edit|WriteafterFileEditChecks the lines the agent just changed and tells it what to fix
pre-readPreToolUse on Readnot installedbeforeReadFileBlocks reading a file that contains a hard-coded secret
pre-bashPreToolUse on Bash|PowerShellPreToolUse on BashbeforeShellExecutionBlocks installs of packages that don't exist or look like typosquats
stopStopStopstopBlocks "done" while issues this session added are still open

The installer also adds a small session-start hook on UserPromptSubmit (Cursor: beforeSubmitPrompt). It records the starting state of the checkout, so changes you made before the agent started aren't blamed on the agent.

Codex has no pre-read hook because Codex reads files through the shell, not through a separate read tool.


The Claude Code config it writes

This is the exact output of skylos agent install-hooks --dry-run --skylos-bin skylos on 4.47.1. Without --skylos-bin, the command is the absolute path of the Skylos that ran the installer, so an older skylos earlier on the agent's PATH can't shadow it.

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "skylos hook session-start --client claude || exit 0", "timeout": 30 }] }
    ],
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [{ "type": "command", "command": "skylos hook pre-read --client claude || exit 0", "timeout": 15 }]
      },
      {
        "matcher": "Bash|PowerShell",
        "hooks": [{ "type": "command", "command": "skylos hook pre-bash --client claude || exit 0", "timeout": 30 }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{ "type": "command", "command": "skylos hook post-edit --client claude || exit 0", "timeout": 120 }]
      }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "skylos hook stop --client claude || echo '{}'", "timeout": 5460 }] }
    ]
  }
}

The Codex file has the same shape with statusMessage fields and no Read hook. The Cursor file uses beforeReadFile, beforeShellExecution, afterFileEdit and stop, and its permission hooks fall back to an explicit {"permission":"allow"} so that a missing Skylos never blocks the agent.

The stop hook's long timeout is deliberate. If you have committed a [tool.skylos.done] table, the stop hook also runs the done checks, which can include your test suite.


What each hook blocks

pre-bash: made-up and look-alike packages

The hook only looks at install commands: pip/pip3/pipx install, python -m pip install, uv pip install, uv add, poetry add, npm install|i|add, pnpm add, yarn add, bun add, go get and go install. Every other command passes straight through without a lookup.

For each named package it checks that the package exists on PyPI, npm or the Go module proxy, and for exact pins, that the version exists. It also flags one-edit look-alikes of popular package names. This is the defence against slopsquatting: a model invents a package name, and someone registers it.

This is the real response when the agent tries to install a name that doesn't exist (a placeholder name here):

{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
 "permissionDecisionReason": "Skylos blocked this install: 1 package looks hallucinated or typosquatted.\n- PyPI package 'skylos-placeholder-pkg-that-does-not-exist-9f3a' does not exist on PyPI (likely hallucinated; an attacker can register it).\nCheck the real package name (docs, registry search) and retry. ..."}}

Allow your own names (internal packages, deliberate look-alikes) in pyproject.toml:

[tool.skylos]
hooks_allow_packages = ["internal-pkg", "npm:my-reactt"]

If the command points at a private index or registry (--index-url, --registry, PIP_INDEX_URL= and similar), Skylos skips the public-registry check. An unreachable registry never blocks an install.

pre-read: files with secrets

Before the agent reads a file, Skylos runs its secrets scanner on it. If it finds a credential, the read is denied, so the key never reaches the model provider:

{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
 "permissionDecisionReason": "Skylos blocked reading pkg/settings_local.py: it contains 2 likely secret(s) (generic secret; github secret) on line(s) 3. Reading it would send the credential to the model provider. Read only the lines you need with offset/limit that skip those lines, ..."}}

The message names the file, the line numbers and the kind of secret, never the value. A read that uses offset/limit to skip those lines is allowed. Test files are scanned too. Binary files and files over 2 MB are skipped.

post-edit: problems in the lines the agent just wrote

After each edit, Skylos works out which lines changed and runs skylos verify on the file with security and secret checks on. It keeps only findings on the changed lines, so existing debt elsewhere in the file is never reported.

It blocks only on:

  1. secrets (SKY-S*);
  2. real untrusted input reaching a dangerous call: the enclosing function has to take a web-route, CLI or MCP-tool parameter, or read request.*, input(), sys.argv, environment variables, stdin or parse_args();
  3. calls that are dangerous whatever the input: pickle.loads, disabled TLS or JWT verification, Trojan Source characters, and eval/exec/os.system/shell=True/yaml.load with a non-constant argument;
  4. hallucinations: dependencies, versions, APIs and references that don't exist (SKY-D222, D224, D225, L012, L023).

Other findings on the changed lines are passed to Claude Code as non-blocking notes. This is a real block message:

Skylos found 1 issue(s) in the lines you just changed. Fix them before moving on:
- pkg/tool.py:6 SKY-D212/SKY-D203 Possible command injection (os.system): tainted input. -> Remove the unsafe sink or validate, escape, or parameterize the untrusted input.
Check again with: skylos hook recheck pkg/tool.py

Writing a secret into a .env file that Git ignores is not blocked, because that's where the key belongs. Writing it into a tracked file is.

Cursor's afterFileEdit event has no way to send feedback to the agent, so on Cursor this hook only records the result. The agent sees the findings at stop.

stop: no "done" with open issues

Each post-edit records a hashed identity of every issue the agent added (rule plus a hash of the line, never the text). At stop, Skylos re-checks every file that still has one, including files that didn't change, because a fix can land in another file. If an issue is still open, the stop is blocked:

Skylos: 1 issue(s) you introduced in this session are still open. Fix them (or explain why they are false positives) before finishing:
- pkg/tool.py:6 SKY-D203/SKY-D212 Use of os.system() -> Remove the unsafe sink or validate, escape, or parameterize the untrusted input.

It doesn't loop forever. If the agent was already sent back once and the issues are unchanged, Claude Code and Codex are allowed to stop and you get a warning. On Cursor, the installer sets a loop_limit on the stop hook.


Fail open, but never silently

Every hook command ends in || exit 0 (or the client's "allow" answer). Skylos itself always exits 0 and puts its decision in JSON. A non-zero exit can only mean "not installed, too old or broken on this machine", and the wrapper turns that into a no-op.

This is a deliberate trade. A crashing checker should not stop your agent from working. But an unchecked action must not look like a checked one, so when Skylos errors it says so:

Skylos could not check this edit: internal error (RuntimeError). It was allowed without a check.

Unchecked actions are also recorded in .skylos/cache/hook-fail-open.json and listed once at stop as a warning. Two cases stay silent: a hook you turned off on purpose, and a Skylos that isn't installed at all.


Latency

Skylos's own measurements, from docs/agent-hooks.md (Apple Silicon laptop, skylos hook run as a subprocess, 10 runs each):

HookCasep50
post-edit13-line file in a small project0.54 s
post-edit25-line file in a repo of about 1,300 Python files, warm index1.14 s
post-editfirst run in a project, no index yet (one-time)5.7 s
pre-readsmall file containing secrets (blocked)0.11 s
pre-bashunrelated command (ls -la)0.08 s
pre-bashpip install requests fastapi, registry answers cached0.29 s
stopone edited file, unchanged since post-edit0.09 s

On a large repo, run skylos agent warm-cache once after installing so the first edit doesn't build the project index. The installer's timeouts are 15 s for pre-read, 30 s for pre-bash and 120 s for post-edit.


What the hooks don't do

Be clear about the limits before you rely on them:

  • They are not a sandbox. They don't restrict which commands run, which files are touched or what the network can reach.
  • They don't undo an edit. PostToolUse runs after the file is written. The hook reports; the agent fixes.
  • They only see lines the agent changed. A deletion that removes a sanitizer or an auth check elsewhere in the file is not attributed to the edit.
  • They don't guard every read. cat .env through the shell and Codex file reads are not blocked.
  • They don't check installs from manifests or URLs (pip install -r, npm ci, pip install git+https://...). Scan manifests with skylos . -a instead.
  • They don't check shell edits at edit time (sed -i, heredocs). Stop still re-checks files that earlier edits recorded.
  • They cover Claude Code, Codex and Cursor only.
  • An agent with file access can turn them off by editing its own hook config. skylos done reports edits to .claude/settings.json, .codex/hooks.json and .cursor/hooks.json as SKY-A114, but the real answer is a required CI check the agent can't edit.

The last point is the same problem people hit with git commit --no-verify skipping pre-commit hooks. Local hooks are fast feedback. A required status check on the pull request is the gate.


Turn one off, or remove them

export SKYLOS_HOOKS_DISABLE=pre-read          # keep the others
export SKYLOS_HOOKS_DISABLE=pre-bash,stop
export SKYLOS_HOOKS_DISABLE=all

skylos agent install-hooks --uninstall        # add --user / --codex / --cursor to match the install

Uninstall removes only hooks whose command runs skylos hook <event>. Everything else in the file stays.

Committed settings. .claude/settings.json is usually committed, but the hook command holds this machine's absolute path. On a teammate's machine without it, the hooks do nothing (they never block). Teammates can run the installer themselves, or you can install with --skylos-bin skylos so each machine uses the skylos on its own PATH. In a Git repo, the installer also adds the hook's local state (.skylos/cache/, .skylos/agent-session.*, .skylos/hook.log*, .skylos/receipts/) to .gitignore.


Add the merge gate

Hooks catch problems while the agent works. The pull request still needs a check that runs whether or not anyone installed hooks:

skylos cicd init --no-upload

This writes .github/workflows/skylos.yml with a changed-line scan job and, when Skylos finds a pytest project or a configured test_command, a "Skylos Done" job that runs your tests and checks for deleted or skipped tests. It needs no Skylos account or API key. --no-upload leaves out the job that uploads default-branch scans to Skylos Cloud. Make both jobs required checks on your default branch.

For what the done job checks and how to read its output, see Your AI agent says the tests pass. For hallucinated imports that are already in the code, see AI-generated code security and the AI defect docs.


References


Try it

pip install skylos
skylos agent install-hooks

Running this across a team? These checks are free in the CLI. Skylos Cloud keeps the results in one place: the Free plan adds a pass/fail check on pull requests and scan history for one project, and the Workspace plan adds up to 10 projects, approvals for findings you decide not to fix, pull request comments, and audit exports.