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
/hooksto see them. - Codex skips new hooks until you trust them. Open
/hooksin Codex and trust the Skylos entries. - Cursor reloads
hooks.jsonby itself.
No account, API key or Docker is needed.
The four hooks
| Skylos hook | Claude Code event | Codex event | Cursor event | What it does |
|---|---|---|---|---|
post-edit | PostToolUse on Edit|Write|MultiEdit | PostToolUse on apply_patch|Edit|Write | afterFileEdit | Checks the lines the agent just changed and tells it what to fix |
pre-read | PreToolUse on Read | not installed | beforeReadFile | Blocks reading a file that contains a hard-coded secret |
pre-bash | PreToolUse on Bash|PowerShell | PreToolUse on Bash | beforeShellExecution | Blocks installs of packages that don't exist or look like typosquats |
stop | Stop | Stop | stop | Blocks "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:
- secrets (SKY-S*);
- 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 orparse_args(); - calls that are dangerous whatever the input:
pickle.loads, disabled TLS or JWT verification, Trojan Source characters, andeval/exec/os.system/shell=True/yaml.loadwith a non-constant argument; - 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):
| Hook | Case | p50 |
|---|---|---|
post-edit | 13-line file in a small project | 0.54 s |
post-edit | 25-line file in a repo of about 1,300 Python files, warm index | 1.14 s |
post-edit | first run in a project, no index yet (one-time) | 5.7 s |
pre-read | small file containing secrets (blocked) | 0.11 s |
pre-bash | unrelated command (ls -la) | 0.08 s |
pre-bash | pip install requests fastapi, registry answers cached | 0.29 s |
stop | one edited file, unchanged since post-edit | 0.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.
PostToolUseruns 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 .envthrough 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 withskylos . -ainstead. - 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 donereports edits to.claude/settings.json,.codex/hooks.jsonand.cursor/hooks.jsonas 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.
Related
- AI coding agent security checklist
- Slopsquatting in Python: hallucinated package names
- Reviewing Claude Code output for security regressions
- Using Skylos with Cursor
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.