failsafe-hook 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- failsafe_hook-1.0.0/LICENSE +21 -0
- failsafe_hook-1.0.0/PKG-INFO +313 -0
- failsafe_hook-1.0.0/README.md +300 -0
- failsafe_hook-1.0.0/pyproject.toml +22 -0
- failsafe_hook-1.0.0/setup.cfg +4 -0
- failsafe_hook-1.0.0/src/failsafe_hook/__init__.py +0 -0
- failsafe_hook-1.0.0/src/failsafe_hook/__main__.py +15 -0
- failsafe_hook-1.0.0/src/failsafe_hook/_install.py +164 -0
- failsafe_hook-1.0.0/src/failsafe_hook/core.py +1712 -0
- failsafe_hook-1.0.0/src/failsafe_hook.egg-info/PKG-INFO +313 -0
- failsafe_hook-1.0.0/src/failsafe_hook.egg-info/SOURCES.txt +12 -0
- failsafe_hook-1.0.0/src/failsafe_hook.egg-info/dependency_links.txt +1 -0
- failsafe_hook-1.0.0/src/failsafe_hook.egg-info/entry_points.txt +2 -0
- failsafe_hook-1.0.0/src/failsafe_hook.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohamed Azahrioui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: failsafe-hook
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zero-config agent seatbelt for Claude Code
|
|
5
|
+
Author-email: Mohamed Azahrioui <mohamedazahrioui2006@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MoAz06/FailSafe
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/MoAz06/FailSafe/issues
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# FailSafe
|
|
15
|
+
|
|
16
|
+
**The zero-config agent seatbelt for Claude Code.**
|
|
17
|
+
|
|
18
|
+
When you run an AI agent in autonomous mode, one bad command can delete your home directory, drop a production database, or install malware from a hallucinated package name. Claude Code has a permissions system for this -- but `--dangerously-skip-permissions` turns it all off.
|
|
19
|
+
|
|
20
|
+
FailSafe runs as a `PreToolUse` hook. **A hook deny still fires in bypass mode.** That is the whole point.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Install the package from PyPI:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pip install failsafe-hook
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install it from GitHub:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install "git+https://github.com/MoAz06/FailSafe.git"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then wire FailSafe into Claude Code:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m failsafe_hook
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If your Python Scripts directory is on PATH, this shortcut also works:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
failsafe
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If Windows says `failsafe` is not found, use `python -m failsafe_hook`. That
|
|
51
|
+
always works.
|
|
52
|
+
|
|
53
|
+
**From source:**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/MoAz06/FailSafe.git
|
|
57
|
+
cd FailSafe
|
|
58
|
+
python install.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`install.py` runs `pip install -e .` automatically if needed, then wires the hook.
|
|
62
|
+
|
|
63
|
+
That's it. FailSafe now runs automatically on every Claude Code session, including bypass mode.
|
|
64
|
+
|
|
65
|
+
**Check status / uninstall:**
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m failsafe_hook --check
|
|
69
|
+
python -m failsafe_hook --uninstall
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Requirements:** Python 3.8+ on your PATH. No other dependencies.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## What it blocks
|
|
77
|
+
|
|
78
|
+
**Module 1 -- Slopsquatting**
|
|
79
|
+
|
|
80
|
+
AI assistants hallucinate package names. Attackers pre-register those names with malware and wait. This is called slopsquatting. In 2025, an estimated 28% of malicious packages were LLM-hallucinated versions of real ones, and 43% of hallucinated names recur on every run of the same prompt.
|
|
81
|
+
|
|
82
|
+
| Situation | Action |
|
|
83
|
+
| :-- | :-- |
|
|
84
|
+
| Package **does not exist** on npm / PyPI / crates.io / RubyGems / Go proxy | Deny |
|
|
85
|
+
| Exists but **one edit away** from a popular package (`expres` vs `express`) | Ask |
|
|
86
|
+
| Package exists but **< 90 days old** with **< 100 downloads/month** (npm/PyPI), **< 500** (Cargo), **< 1 000** total (RubyGems) | Ask |
|
|
87
|
+
| Everything else | Allow (silent) |
|
|
88
|
+
|
|
89
|
+
**Module 2 -- Destructive commands**
|
|
90
|
+
|
|
91
|
+
| Situation | Action |
|
|
92
|
+
| :-- | :-- |
|
|
93
|
+
| `rm -rf` on `/`, `~`, `$HOME`, `/etc`, `/usr`, `/home/<user>`, system dirs, or root globs | Deny |
|
|
94
|
+
| `rm -rf .git` (destroys repo history) | Deny |
|
|
95
|
+
| Windows paths: `/c/`, `/c/Users`, `$USERPROFILE`, `$WINDIR`, `$SYSTEMROOT` | Deny |
|
|
96
|
+
| `rm -rf` on relative paths, `/tmp/...`, or deep subdirectories | Allow |
|
|
97
|
+
|
|
98
|
+
**Module 3 -- One-off runners**
|
|
99
|
+
|
|
100
|
+
| Situation | Action |
|
|
101
|
+
| :-- | :-- |
|
|
102
|
+
| `npx`, `npm exec`, `pnpm dlx`, `bunx`, `bun x`, `yarn dlx` runs a non-existent package | Deny |
|
|
103
|
+
| Runner target is a look-alike or suspiciously fresh | Ask |
|
|
104
|
+
| Local paths such as `npx ./scripts/tool.js` | Allow |
|
|
105
|
+
|
|
106
|
+
**Module 4 -- Manifest installs**
|
|
107
|
+
|
|
108
|
+
A bare install pulls every dependency from a manifest file. An agent can inject a hallucinated package into `package.json` or `requirements.txt` and then run a bare install. FailSafe reads the manifest first.
|
|
109
|
+
|
|
110
|
+
| Situation | Action |
|
|
111
|
+
| :-- | :-- |
|
|
112
|
+
| `npm/pnpm/yarn/bun install` reads `package.json` -- a dep does not exist | Deny |
|
|
113
|
+
| `pip install -r <file>` / `uv pip install -r <file>` -- a dep does not exist | Deny |
|
|
114
|
+
| `poetry install` / `uv sync` reads `pyproject.toml` -- a dep does not exist | Deny |
|
|
115
|
+
| Local/git/url/workspace specs | Ignored |
|
|
116
|
+
|
|
117
|
+
**Module 5 -- Curl-pipe-shell**
|
|
118
|
+
|
|
119
|
+
| Situation | Action |
|
|
120
|
+
| :-- | :-- |
|
|
121
|
+
| `curl http://... \| bash` -- plain HTTP remote script | Deny |
|
|
122
|
+
| `curl https://... \| bash/sh/python/node/ruby` | Ask |
|
|
123
|
+
| `wget ... \| sh` | Ask |
|
|
124
|
+
| `curl ... \| grep` or piped to non-shell | Allow |
|
|
125
|
+
|
|
126
|
+
**Module 6 -- Git disaster**
|
|
127
|
+
|
|
128
|
+
| Situation | Action |
|
|
129
|
+
| :-- | :-- |
|
|
130
|
+
| `git push --force` / `git push origin +main` to `main`, `master`, `production`, `release`, `prod`, `staging` | Deny |
|
|
131
|
+
| `git push --force` / `+refspec` to any other branch, or `--force-with-lease` | Ask |
|
|
132
|
+
| `git reset --hard` or `--merge` | Ask |
|
|
133
|
+
| `git clean -f` / `-fd` / `-fdx` (without dry-run) | Ask |
|
|
134
|
+
| `git branch -D <branch>` | Ask |
|
|
135
|
+
| Normal git operations | Allow |
|
|
136
|
+
|
|
137
|
+
**Module 7 -- Cloud / infra blast radius**
|
|
138
|
+
|
|
139
|
+
| Situation | Action |
|
|
140
|
+
| :-- | :-- |
|
|
141
|
+
| `terraform destroy` | Ask |
|
|
142
|
+
| `kubectl delete namespace` / `--all` / `-A` | Ask |
|
|
143
|
+
| `docker system prune -a` | Ask |
|
|
144
|
+
| `docker volume rm` | Ask |
|
|
145
|
+
| `aws s3 rm --recursive` / `sync --delete` | Ask |
|
|
146
|
+
| `gcloud projects delete` | Ask |
|
|
147
|
+
| `az group delete` | Ask |
|
|
148
|
+
| Normal read/plan operations | Allow |
|
|
149
|
+
|
|
150
|
+
**Module 8 -- Secrets exfiltration**
|
|
151
|
+
|
|
152
|
+
| Situation | Action |
|
|
153
|
+
| :-- | :-- |
|
|
154
|
+
| `cat .env \| curl` / `cat ~/.ssh/id_rsa \| nc` | Ask |
|
|
155
|
+
| `curl -d @.env https://...` / `curl -F file=@.env` | Ask |
|
|
156
|
+
| `scp .env user@host:.` / `rsync .env user@host:` | Ask |
|
|
157
|
+
| `tar czf - ~/.ssh \| curl --data-binary @- https://...` | Ask |
|
|
158
|
+
| `aws s3 cp .env s3://bucket` / `aws s3 mv id_rsa s3://...` | Ask |
|
|
159
|
+
| Reading `.env` locally without network | Allow |
|
|
160
|
+
|
|
161
|
+
Sensitive file patterns: `.env`, `.env.*`, `~/.ssh/*`, `*.pem`, `*.key`, `*.p12`, `id_rsa`, `id_ed25519`, `.netrc`, `.npmrc`, `.aws/credentials`.
|
|
162
|
+
|
|
163
|
+
**Module 9 -- Cargo, Go, and RubyGems**
|
|
164
|
+
|
|
165
|
+
| Situation | Action |
|
|
166
|
+
| :-- | :-- |
|
|
167
|
+
| `cargo add` / `cargo install` -- crate not found on crates.io | Deny |
|
|
168
|
+
| Crate one edit away from a popular crate (`serde_jso` vs `serde_json`) | Ask |
|
|
169
|
+
| Crate < 90 days old with < 500 recent downloads | Ask |
|
|
170
|
+
| `go get` / `go install` -- module not found on the Go module proxy | Deny |
|
|
171
|
+
| `gem install` / `bundle add` -- gem not found on RubyGems | Deny |
|
|
172
|
+
| Gem one edit away from a popular gem (`railss` vs `rails`) | Ask |
|
|
173
|
+
| Gem < 90 days old with < 1 000 total downloads | Ask |
|
|
174
|
+
| `--git`, `--path`, or local `.gem` file sources | Ignored (not registry installs) |
|
|
175
|
+
| Private/unknown Go module domains | Ignored (avoids false positives) |
|
|
176
|
+
|
|
177
|
+
**Configuration (`failsafe.toml`)**
|
|
178
|
+
|
|
179
|
+
Place a `failsafe.toml` in your project root (or `~/.config/failsafe/config.toml`) to tune behavior:
|
|
180
|
+
|
|
181
|
+
```toml
|
|
182
|
+
[failsafe]
|
|
183
|
+
strict = false # true: upgrade all "ask" to "deny"
|
|
184
|
+
protected_branches = ["main", "prod"] # replaces the default protected list
|
|
185
|
+
allowed_packages = ["my-internal-tool"] # always allow these names
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
All settings are optional. Zero-config remains the default.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Blocked command examples
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"npm install totally-not-real-pkg-xyz123"}}' \
|
|
196
|
+
| python hooks/failsafe.py
|
|
197
|
+
|
|
198
|
+
permissionDecision: deny
|
|
199
|
+
reason: FailSafe blocked this install:
|
|
200
|
+
- totally-not-real-pkg-xyz123: not found on the npm registry
|
|
201
|
+
|
|
202
|
+
AI assistants sometimes invent package names that don't exist; attackers
|
|
203
|
+
pre-register those names with malware ("slopsquatting"). Verify the correct
|
|
204
|
+
name on the official registry before installing.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
|
|
209
|
+
| python hooks/failsafe.py
|
|
210
|
+
|
|
211
|
+
permissionDecision: deny
|
|
212
|
+
reason: FailSafe blocked a destructive command:
|
|
213
|
+
rm -rf /
|
|
214
|
+
This would permanently delete the filesystem root (/).
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main --force"}}' \
|
|
219
|
+
| python hooks/failsafe.py
|
|
220
|
+
|
|
221
|
+
permissionDecision: deny
|
|
222
|
+
reason: FailSafe blocked a force push to a protected branch:
|
|
223
|
+
git push origin main --force
|
|
224
|
+
Force pushing to 'main' permanently overwrites remote history.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Design principles
|
|
230
|
+
|
|
231
|
+
- **Fails open** -- any network error or unexpected failure allows the action. A guard that breaks your workflow gets uninstalled.
|
|
232
|
+
- **Fast path** -- non-network rules (rm, git, cloud, secrets) return instantly with zero I/O.
|
|
233
|
+
- **Conservative** -- only near-certain danger hard-blocks. Fuzzy signals escalate to a prompt.
|
|
234
|
+
- **Zero dependencies** -- pure Python 3 standard library.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## How it works
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
PreToolUse hook -> parse the Bash command
|
|
242
|
+
-> run instant checks (rm, git, cloud, curl-pipe, secrets)
|
|
243
|
+
-> for install commands, query npm / PyPI registries
|
|
244
|
+
-> deny if dangerous / ask if suspicious / allow otherwise
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`hooks/hooks.json` wires the hook on the `Bash` tool. `hooks/failsafe.py` does the work.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## What FailSafe will not do
|
|
252
|
+
|
|
253
|
+
- Score package reputation the way Socket.dev or Snyk do -- it checks existence and simple heuristics, not full supply chain analysis.
|
|
254
|
+
- Catch every possible dangerous command -- it targets the patterns agents actually produce, not a complete policy engine.
|
|
255
|
+
- Protect against a compromised package that already exists on the registry.
|
|
256
|
+
- Inspect `pnpm-lock.yaml` (no stdlib YAML parser).
|
|
257
|
+
- Parse `pyproject.toml` or `failsafe.toml` on Python < 3.11 (no `tomllib` -- fails open).
|
|
258
|
+
- Catch runner command strings that embed the package inside a shell snippet (`npm exec -c "eslint ."`).
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Requirements
|
|
263
|
+
|
|
264
|
+
- **Python 3.8+** on your PATH (`python3` or `python`).
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Scope: what it checks
|
|
269
|
+
|
|
270
|
+
**Install commands:** `npm install|i|add`, `pnpm add|install|i`, `yarn add`, `bun add|install|i`, `pip install`, `pip3 install`, `python -m pip install`, `poetry add`, `uv add`, `uv pip install`, `cargo add`, `cargo install`, `go get`, `go install`, `gem install`, `bundle add`
|
|
271
|
+
|
|
272
|
+
**One-off runners:** `npx`, `npm exec|x`, `pnpm dlx`, `bunx`, `bun x`, `yarn dlx`
|
|
273
|
+
|
|
274
|
+
**Manifest/lockfiles on bare install:** `package.json`, `package-lock.json` v2/v3 (npm ci), `yarn.lock` v1, `requirements.txt`, `pyproject.toml`, `poetry.lock`
|
|
275
|
+
|
|
276
|
+
Handles: quoted names, `;` and `&&` operators, `env FOO=bar` prefixes, wrapper flags (`sudo -n`, `env -i`, `time -p`), `bash -c "..."` inner commands.
|
|
277
|
+
|
|
278
|
+
Ignores: local paths, git URLs, `.tgz`/`.whl` files.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Limitations
|
|
283
|
+
|
|
284
|
+
- npm, PyPI, crates.io, RubyGems, and Go proxy. Maven is next.
|
|
285
|
+
- Look-alike detection uses a curated list of popular packages, not a full corpus.
|
|
286
|
+
- `pnpm-lock.yaml` not inspected (no stdlib YAML parser).
|
|
287
|
+
- Deeply embedded runner command strings are not inspected (`npm exec -c "eslint ."`).
|
|
288
|
+
- `pyproject.toml`, `poetry.lock`, and `failsafe.toml` require Python 3.11+ (`tomllib`); fails open on older Python.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Contributing
|
|
293
|
+
|
|
294
|
+
To add a new rule:
|
|
295
|
+
|
|
296
|
+
1. Add the detection logic to `hooks/failsafe.py` as a new `check_*` function.
|
|
297
|
+
2. Wire it into `main()` before the registry lookups (if it needs no network) or after (if it does).
|
|
298
|
+
3. Add a `hooks/test_<name>.py` with PASS/FAIL cases following the existing pattern.
|
|
299
|
+
4. Add an e2e case to `hooks/test_e2e.py`.
|
|
300
|
+
5. Run all tests: all existing 265+ cases must still pass.
|
|
301
|
+
|
|
302
|
+
To add a new package ecosystem:
|
|
303
|
+
|
|
304
|
+
1. Add a `check_<ecosystem>` registry function following `check_npm` / `check_pypi`.
|
|
305
|
+
2. Extend `parse_install_targets` to recognise the new package manager.
|
|
306
|
+
3. Add look-alike detection if a popular-package list exists for the ecosystem.
|
|
307
|
+
4. Add focused tests.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
MIT
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# FailSafe
|
|
2
|
+
|
|
3
|
+
**The zero-config agent seatbelt for Claude Code.**
|
|
4
|
+
|
|
5
|
+
When you run an AI agent in autonomous mode, one bad command can delete your home directory, drop a production database, or install malware from a hallucinated package name. Claude Code has a permissions system for this -- but `--dangerously-skip-permissions` turns it all off.
|
|
6
|
+
|
|
7
|
+
FailSafe runs as a `PreToolUse` hook. **A hook deny still fires in bypass mode.** That is the whole point.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Install the package from PyPI:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install failsafe-hook
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it from GitHub:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install "git+https://github.com/MoAz06/FailSafe.git"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then wire FailSafe into Claude Code:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python -m failsafe_hook
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If your Python Scripts directory is on PATH, this shortcut also works:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
failsafe
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If Windows says `failsafe` is not found, use `python -m failsafe_hook`. That
|
|
38
|
+
always works.
|
|
39
|
+
|
|
40
|
+
**From source:**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/MoAz06/FailSafe.git
|
|
44
|
+
cd FailSafe
|
|
45
|
+
python install.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`install.py` runs `pip install -e .` automatically if needed, then wires the hook.
|
|
49
|
+
|
|
50
|
+
That's it. FailSafe now runs automatically on every Claude Code session, including bypass mode.
|
|
51
|
+
|
|
52
|
+
**Check status / uninstall:**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m failsafe_hook --check
|
|
56
|
+
python -m failsafe_hook --uninstall
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Requirements:** Python 3.8+ on your PATH. No other dependencies.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## What it blocks
|
|
64
|
+
|
|
65
|
+
**Module 1 -- Slopsquatting**
|
|
66
|
+
|
|
67
|
+
AI assistants hallucinate package names. Attackers pre-register those names with malware and wait. This is called slopsquatting. In 2025, an estimated 28% of malicious packages were LLM-hallucinated versions of real ones, and 43% of hallucinated names recur on every run of the same prompt.
|
|
68
|
+
|
|
69
|
+
| Situation | Action |
|
|
70
|
+
| :-- | :-- |
|
|
71
|
+
| Package **does not exist** on npm / PyPI / crates.io / RubyGems / Go proxy | Deny |
|
|
72
|
+
| Exists but **one edit away** from a popular package (`expres` vs `express`) | Ask |
|
|
73
|
+
| Package exists but **< 90 days old** with **< 100 downloads/month** (npm/PyPI), **< 500** (Cargo), **< 1 000** total (RubyGems) | Ask |
|
|
74
|
+
| Everything else | Allow (silent) |
|
|
75
|
+
|
|
76
|
+
**Module 2 -- Destructive commands**
|
|
77
|
+
|
|
78
|
+
| Situation | Action |
|
|
79
|
+
| :-- | :-- |
|
|
80
|
+
| `rm -rf` on `/`, `~`, `$HOME`, `/etc`, `/usr`, `/home/<user>`, system dirs, or root globs | Deny |
|
|
81
|
+
| `rm -rf .git` (destroys repo history) | Deny |
|
|
82
|
+
| Windows paths: `/c/`, `/c/Users`, `$USERPROFILE`, `$WINDIR`, `$SYSTEMROOT` | Deny |
|
|
83
|
+
| `rm -rf` on relative paths, `/tmp/...`, or deep subdirectories | Allow |
|
|
84
|
+
|
|
85
|
+
**Module 3 -- One-off runners**
|
|
86
|
+
|
|
87
|
+
| Situation | Action |
|
|
88
|
+
| :-- | :-- |
|
|
89
|
+
| `npx`, `npm exec`, `pnpm dlx`, `bunx`, `bun x`, `yarn dlx` runs a non-existent package | Deny |
|
|
90
|
+
| Runner target is a look-alike or suspiciously fresh | Ask |
|
|
91
|
+
| Local paths such as `npx ./scripts/tool.js` | Allow |
|
|
92
|
+
|
|
93
|
+
**Module 4 -- Manifest installs**
|
|
94
|
+
|
|
95
|
+
A bare install pulls every dependency from a manifest file. An agent can inject a hallucinated package into `package.json` or `requirements.txt` and then run a bare install. FailSafe reads the manifest first.
|
|
96
|
+
|
|
97
|
+
| Situation | Action |
|
|
98
|
+
| :-- | :-- |
|
|
99
|
+
| `npm/pnpm/yarn/bun install` reads `package.json` -- a dep does not exist | Deny |
|
|
100
|
+
| `pip install -r <file>` / `uv pip install -r <file>` -- a dep does not exist | Deny |
|
|
101
|
+
| `poetry install` / `uv sync` reads `pyproject.toml` -- a dep does not exist | Deny |
|
|
102
|
+
| Local/git/url/workspace specs | Ignored |
|
|
103
|
+
|
|
104
|
+
**Module 5 -- Curl-pipe-shell**
|
|
105
|
+
|
|
106
|
+
| Situation | Action |
|
|
107
|
+
| :-- | :-- |
|
|
108
|
+
| `curl http://... \| bash` -- plain HTTP remote script | Deny |
|
|
109
|
+
| `curl https://... \| bash/sh/python/node/ruby` | Ask |
|
|
110
|
+
| `wget ... \| sh` | Ask |
|
|
111
|
+
| `curl ... \| grep` or piped to non-shell | Allow |
|
|
112
|
+
|
|
113
|
+
**Module 6 -- Git disaster**
|
|
114
|
+
|
|
115
|
+
| Situation | Action |
|
|
116
|
+
| :-- | :-- |
|
|
117
|
+
| `git push --force` / `git push origin +main` to `main`, `master`, `production`, `release`, `prod`, `staging` | Deny |
|
|
118
|
+
| `git push --force` / `+refspec` to any other branch, or `--force-with-lease` | Ask |
|
|
119
|
+
| `git reset --hard` or `--merge` | Ask |
|
|
120
|
+
| `git clean -f` / `-fd` / `-fdx` (without dry-run) | Ask |
|
|
121
|
+
| `git branch -D <branch>` | Ask |
|
|
122
|
+
| Normal git operations | Allow |
|
|
123
|
+
|
|
124
|
+
**Module 7 -- Cloud / infra blast radius**
|
|
125
|
+
|
|
126
|
+
| Situation | Action |
|
|
127
|
+
| :-- | :-- |
|
|
128
|
+
| `terraform destroy` | Ask |
|
|
129
|
+
| `kubectl delete namespace` / `--all` / `-A` | Ask |
|
|
130
|
+
| `docker system prune -a` | Ask |
|
|
131
|
+
| `docker volume rm` | Ask |
|
|
132
|
+
| `aws s3 rm --recursive` / `sync --delete` | Ask |
|
|
133
|
+
| `gcloud projects delete` | Ask |
|
|
134
|
+
| `az group delete` | Ask |
|
|
135
|
+
| Normal read/plan operations | Allow |
|
|
136
|
+
|
|
137
|
+
**Module 8 -- Secrets exfiltration**
|
|
138
|
+
|
|
139
|
+
| Situation | Action |
|
|
140
|
+
| :-- | :-- |
|
|
141
|
+
| `cat .env \| curl` / `cat ~/.ssh/id_rsa \| nc` | Ask |
|
|
142
|
+
| `curl -d @.env https://...` / `curl -F file=@.env` | Ask |
|
|
143
|
+
| `scp .env user@host:.` / `rsync .env user@host:` | Ask |
|
|
144
|
+
| `tar czf - ~/.ssh \| curl --data-binary @- https://...` | Ask |
|
|
145
|
+
| `aws s3 cp .env s3://bucket` / `aws s3 mv id_rsa s3://...` | Ask |
|
|
146
|
+
| Reading `.env` locally without network | Allow |
|
|
147
|
+
|
|
148
|
+
Sensitive file patterns: `.env`, `.env.*`, `~/.ssh/*`, `*.pem`, `*.key`, `*.p12`, `id_rsa`, `id_ed25519`, `.netrc`, `.npmrc`, `.aws/credentials`.
|
|
149
|
+
|
|
150
|
+
**Module 9 -- Cargo, Go, and RubyGems**
|
|
151
|
+
|
|
152
|
+
| Situation | Action |
|
|
153
|
+
| :-- | :-- |
|
|
154
|
+
| `cargo add` / `cargo install` -- crate not found on crates.io | Deny |
|
|
155
|
+
| Crate one edit away from a popular crate (`serde_jso` vs `serde_json`) | Ask |
|
|
156
|
+
| Crate < 90 days old with < 500 recent downloads | Ask |
|
|
157
|
+
| `go get` / `go install` -- module not found on the Go module proxy | Deny |
|
|
158
|
+
| `gem install` / `bundle add` -- gem not found on RubyGems | Deny |
|
|
159
|
+
| Gem one edit away from a popular gem (`railss` vs `rails`) | Ask |
|
|
160
|
+
| Gem < 90 days old with < 1 000 total downloads | Ask |
|
|
161
|
+
| `--git`, `--path`, or local `.gem` file sources | Ignored (not registry installs) |
|
|
162
|
+
| Private/unknown Go module domains | Ignored (avoids false positives) |
|
|
163
|
+
|
|
164
|
+
**Configuration (`failsafe.toml`)**
|
|
165
|
+
|
|
166
|
+
Place a `failsafe.toml` in your project root (or `~/.config/failsafe/config.toml`) to tune behavior:
|
|
167
|
+
|
|
168
|
+
```toml
|
|
169
|
+
[failsafe]
|
|
170
|
+
strict = false # true: upgrade all "ask" to "deny"
|
|
171
|
+
protected_branches = ["main", "prod"] # replaces the default protected list
|
|
172
|
+
allowed_packages = ["my-internal-tool"] # always allow these names
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
All settings are optional. Zero-config remains the default.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Blocked command examples
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"npm install totally-not-real-pkg-xyz123"}}' \
|
|
183
|
+
| python hooks/failsafe.py
|
|
184
|
+
|
|
185
|
+
permissionDecision: deny
|
|
186
|
+
reason: FailSafe blocked this install:
|
|
187
|
+
- totally-not-real-pkg-xyz123: not found on the npm registry
|
|
188
|
+
|
|
189
|
+
AI assistants sometimes invent package names that don't exist; attackers
|
|
190
|
+
pre-register those names with malware ("slopsquatting"). Verify the correct
|
|
191
|
+
name on the official registry before installing.
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
|
|
196
|
+
| python hooks/failsafe.py
|
|
197
|
+
|
|
198
|
+
permissionDecision: deny
|
|
199
|
+
reason: FailSafe blocked a destructive command:
|
|
200
|
+
rm -rf /
|
|
201
|
+
This would permanently delete the filesystem root (/).
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
$ echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main --force"}}' \
|
|
206
|
+
| python hooks/failsafe.py
|
|
207
|
+
|
|
208
|
+
permissionDecision: deny
|
|
209
|
+
reason: FailSafe blocked a force push to a protected branch:
|
|
210
|
+
git push origin main --force
|
|
211
|
+
Force pushing to 'main' permanently overwrites remote history.
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Design principles
|
|
217
|
+
|
|
218
|
+
- **Fails open** -- any network error or unexpected failure allows the action. A guard that breaks your workflow gets uninstalled.
|
|
219
|
+
- **Fast path** -- non-network rules (rm, git, cloud, secrets) return instantly with zero I/O.
|
|
220
|
+
- **Conservative** -- only near-certain danger hard-blocks. Fuzzy signals escalate to a prompt.
|
|
221
|
+
- **Zero dependencies** -- pure Python 3 standard library.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## How it works
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
PreToolUse hook -> parse the Bash command
|
|
229
|
+
-> run instant checks (rm, git, cloud, curl-pipe, secrets)
|
|
230
|
+
-> for install commands, query npm / PyPI registries
|
|
231
|
+
-> deny if dangerous / ask if suspicious / allow otherwise
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`hooks/hooks.json` wires the hook on the `Bash` tool. `hooks/failsafe.py` does the work.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## What FailSafe will not do
|
|
239
|
+
|
|
240
|
+
- Score package reputation the way Socket.dev or Snyk do -- it checks existence and simple heuristics, not full supply chain analysis.
|
|
241
|
+
- Catch every possible dangerous command -- it targets the patterns agents actually produce, not a complete policy engine.
|
|
242
|
+
- Protect against a compromised package that already exists on the registry.
|
|
243
|
+
- Inspect `pnpm-lock.yaml` (no stdlib YAML parser).
|
|
244
|
+
- Parse `pyproject.toml` or `failsafe.toml` on Python < 3.11 (no `tomllib` -- fails open).
|
|
245
|
+
- Catch runner command strings that embed the package inside a shell snippet (`npm exec -c "eslint ."`).
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Requirements
|
|
250
|
+
|
|
251
|
+
- **Python 3.8+** on your PATH (`python3` or `python`).
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Scope: what it checks
|
|
256
|
+
|
|
257
|
+
**Install commands:** `npm install|i|add`, `pnpm add|install|i`, `yarn add`, `bun add|install|i`, `pip install`, `pip3 install`, `python -m pip install`, `poetry add`, `uv add`, `uv pip install`, `cargo add`, `cargo install`, `go get`, `go install`, `gem install`, `bundle add`
|
|
258
|
+
|
|
259
|
+
**One-off runners:** `npx`, `npm exec|x`, `pnpm dlx`, `bunx`, `bun x`, `yarn dlx`
|
|
260
|
+
|
|
261
|
+
**Manifest/lockfiles on bare install:** `package.json`, `package-lock.json` v2/v3 (npm ci), `yarn.lock` v1, `requirements.txt`, `pyproject.toml`, `poetry.lock`
|
|
262
|
+
|
|
263
|
+
Handles: quoted names, `;` and `&&` operators, `env FOO=bar` prefixes, wrapper flags (`sudo -n`, `env -i`, `time -p`), `bash -c "..."` inner commands.
|
|
264
|
+
|
|
265
|
+
Ignores: local paths, git URLs, `.tgz`/`.whl` files.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Limitations
|
|
270
|
+
|
|
271
|
+
- npm, PyPI, crates.io, RubyGems, and Go proxy. Maven is next.
|
|
272
|
+
- Look-alike detection uses a curated list of popular packages, not a full corpus.
|
|
273
|
+
- `pnpm-lock.yaml` not inspected (no stdlib YAML parser).
|
|
274
|
+
- Deeply embedded runner command strings are not inspected (`npm exec -c "eslint ."`).
|
|
275
|
+
- `pyproject.toml`, `poetry.lock`, and `failsafe.toml` require Python 3.11+ (`tomllib`); fails open on older Python.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Contributing
|
|
280
|
+
|
|
281
|
+
To add a new rule:
|
|
282
|
+
|
|
283
|
+
1. Add the detection logic to `hooks/failsafe.py` as a new `check_*` function.
|
|
284
|
+
2. Wire it into `main()` before the registry lookups (if it needs no network) or after (if it does).
|
|
285
|
+
3. Add a `hooks/test_<name>.py` with PASS/FAIL cases following the existing pattern.
|
|
286
|
+
4. Add an e2e case to `hooks/test_e2e.py`.
|
|
287
|
+
5. Run all tests: all existing 265+ cases must still pass.
|
|
288
|
+
|
|
289
|
+
To add a new package ecosystem:
|
|
290
|
+
|
|
291
|
+
1. Add a `check_<ecosystem>` registry function following `check_npm` / `check_pypi`.
|
|
292
|
+
2. Extend `parse_install_targets` to recognise the new package manager.
|
|
293
|
+
3. Add look-alike detection if a popular-package list exists for the ecosystem.
|
|
294
|
+
4. Add focused tests.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "failsafe-hook"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Zero-config agent seatbelt for Claude Code"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Mohamed Azahrioui", email = "mohamedazahrioui2006@gmail.com"}]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
failsafe = "failsafe_hook.__main__:main"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/MoAz06/FailSafe"
|
|
19
|
+
"Bug Tracker" = "https://github.com/MoAz06/FailSafe/issues"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
args = sys.argv[1:]
|
|
7
|
+
if args or sys.stdin.isatty():
|
|
8
|
+
from failsafe_hook._install import main as _main
|
|
9
|
+
else:
|
|
10
|
+
from failsafe_hook.core import main as _main
|
|
11
|
+
_main()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|