capt-hook 6.4.0__tar.gz → 7.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.
- capt_hook-7.0.0/PKG-INFO +144 -0
- capt_hook-7.0.0/README.md +98 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/__init__.py +10 -3
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/app.py +1 -1
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/cli.py +6 -1
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/conditions.py +8 -2
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/context.py +3 -2
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/dispatch.py +15 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/events.py +51 -10
- capt_hook-7.0.0/captain_hook/packs/fixes/capt-hook.toml +4 -0
- capt_hook-7.0.0/captain_hook/packs/fixes/teammate_permissions.py +66 -0
- capt_hook-7.0.0/captain_hook/packs/general/capt-hook.toml +5 -0
- capt_hook-7.0.0/captain_hook/packs/general/detours.py +151 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/go/testing.py +6 -6
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/python/testing.py +8 -6
- capt_hook-7.0.0/captain_hook/packs/steering/__init__.py +1 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/steering/steering.py +9 -1
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/__init__.py +1 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/commands.py +2 -1
- capt_hook-7.0.0/captain_hook/primitives/permissions.py +162 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/prompt.py +15 -2
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +34 -12
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +7 -5
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -1
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +2 -2
- capt_hook-7.0.0/captain_hook/testing/__init__.py +11 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/testing/helpers.py +25 -6
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/testing/types.py +28 -5
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/types.py +51 -8
- capt_hook-7.0.0/captain_hook/util/automode.py +53 -0
- capt_hook-7.0.0/captain_hook/util/proc.py +59 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/pyproject.toml +9 -5
- capt_hook-6.4.0/PKG-INFO +0 -137
- capt_hook-6.4.0/README.md +0 -89
- capt_hook-6.4.0/captain_hook/command.py +0 -382
- capt_hook-6.4.0/captain_hook/packs/general/capt-hook.toml +0 -5
- capt_hook-6.4.0/captain_hook/testing/__init__.py +0 -1
- {capt_hook-6.4.0 → capt_hook-7.0.0}/LICENSE +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/__main__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/contexts.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/decisions.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/durable.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/fields.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/file.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/loader.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/log.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-6.4.0/captain_hook/packs/general → capt_hook-7.0.0/captain_hook/packs/fixes}/__init__.py +0 -0
- {capt_hook-6.4.0/captain_hook/packs/go → capt_hook-7.0.0/captain_hook/packs/general}/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/models.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-6.4.0/captain_hook/packs/python → capt_hook-7.0.0/captain_hook/packs/go}/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-6.4.0/captain_hook/packs/steering → capt_hook-7.0.0/captain_hook/packs/python}/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/py.typed +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/store.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/session.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/settings.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/state.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/style/types.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/tasks.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/util/http.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-6.4.0 → capt_hook-7.0.0}/captain_hook/util/text.py +0 -0
capt_hook-7.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capt-hook
|
|
3
|
+
Version: 7.0.0
|
|
4
|
+
Summary: Stop repeating yourself to Claude.
|
|
5
|
+
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
|
+
Author: Yasyf Mohamedali
|
|
7
|
+
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
8
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: cc-transcript>=8,<9
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
22
|
+
Requires-Dist: ast-grep-py>=0.39,<1
|
|
23
|
+
Requires-Dist: funcy>=2.0
|
|
24
|
+
Requires-Dist: spacy>=3.7
|
|
25
|
+
Requires-Dist: click>=8
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: orjsonl>=1.0
|
|
28
|
+
Requires-Dist: wn>=1.1.0
|
|
29
|
+
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
30
|
+
Requires-Dist: filelock>=3
|
|
31
|
+
Requires-Dist: loguru>=0.7.3
|
|
32
|
+
Requires-Dist: spawnllm>=0.5.2,<0.6
|
|
33
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pyyaml>=6 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
38
|
+
Requires-Python: >=3.13
|
|
39
|
+
Project-URL: Homepage, https://github.com/yasyf/captain-hook
|
|
40
|
+
Project-URL: Documentation, https://yasyf.github.io/captain-hook/
|
|
41
|
+
Project-URL: Repository, https://github.com/yasyf/captain-hook
|
|
42
|
+
Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
|
|
43
|
+
Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# 
|
|
48
|
+
|
|
49
|
+
**Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
|
|
50
|
+
|
|
51
|
+
[](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
|
|
52
|
+
[](https://pypi.org/project/capt-hook/)
|
|
53
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
54
|
+
|
|
55
|
+
## Get started
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uvx capt-hook init
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
|
|
62
|
+
|
|
63
|
+
<img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
|
|
64
|
+
|
|
65
|
+
Driving with an agent? Paste this:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
/plugin marketplace add yasyf/captain-hook
|
|
69
|
+
/plugin install captain-hook@captain-hook
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary>Prefer a prompt over the plugin?</summary>
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
|
|
77
|
+
and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
|
|
78
|
+
if you get stuck.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
</details>
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Use cases
|
|
86
|
+
|
|
87
|
+
### Block force-push and rm -rf before they run
|
|
88
|
+
|
|
89
|
+
One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# .claude/hooks/safety.py
|
|
93
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
94
|
+
|
|
95
|
+
block_command(
|
|
96
|
+
["git", "push", "--force"],
|
|
97
|
+
reason="Force-pushing rewrites shared history",
|
|
98
|
+
hint="Use `git push --force-with-lease` instead",
|
|
99
|
+
tests={
|
|
100
|
+
Input(command="git push --force"): Block(),
|
|
101
|
+
Input(command="git push origin main"): Allow(),
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
|
|
107
|
+
|
|
108
|
+
### Turn repeated corrections into rules Claude can't forget
|
|
109
|
+
|
|
110
|
+
You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uvx capt-hook status
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
|
|
117
|
+
|
|
118
|
+
### Gate "done" until the tests actually pass
|
|
119
|
+
|
|
120
|
+
The agent declares victory while the suite is red. A Stop gate holds the line:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# .claude/hooks/quality.py
|
|
124
|
+
from captain_hook import RanCommand, TouchedFile, gate
|
|
125
|
+
|
|
126
|
+
gate(
|
|
127
|
+
"You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
|
|
128
|
+
only_if=[TouchedFile("**/*.py")],
|
|
129
|
+
skip_if=[RanCommand(r"\bpytest\b")],
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
|
|
134
|
+
|
|
135
|
+
## More in the docs
|
|
136
|
+
|
|
137
|
+
- **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
|
|
138
|
+
- **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
|
|
139
|
+
- **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
|
|
140
|
+
- **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
|
|
141
|
+
- **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
|
|
142
|
+
- **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
|
|
143
|
+
|
|
144
|
+
Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 
|
|
2
|
+
|
|
3
|
+
**Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/capt-hook/)
|
|
7
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
## Get started
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx capt-hook init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
|
|
16
|
+
|
|
17
|
+
<img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
|
|
18
|
+
|
|
19
|
+
Driving with an agent? Paste this:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
/plugin marketplace add yasyf/captain-hook
|
|
23
|
+
/plugin install captain-hook@captain-hook
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
<details>
|
|
27
|
+
<summary>Prefer a prompt over the plugin?</summary>
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
|
|
31
|
+
and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
|
|
32
|
+
if you get stuck.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
</details>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Use cases
|
|
40
|
+
|
|
41
|
+
### Block force-push and rm -rf before they run
|
|
42
|
+
|
|
43
|
+
One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# .claude/hooks/safety.py
|
|
47
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
48
|
+
|
|
49
|
+
block_command(
|
|
50
|
+
["git", "push", "--force"],
|
|
51
|
+
reason="Force-pushing rewrites shared history",
|
|
52
|
+
hint="Use `git push --force-with-lease` instead",
|
|
53
|
+
tests={
|
|
54
|
+
Input(command="git push --force"): Block(),
|
|
55
|
+
Input(command="git push origin main"): Allow(),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
|
|
61
|
+
|
|
62
|
+
### Turn repeated corrections into rules Claude can't forget
|
|
63
|
+
|
|
64
|
+
You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uvx capt-hook status
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
|
|
71
|
+
|
|
72
|
+
### Gate "done" until the tests actually pass
|
|
73
|
+
|
|
74
|
+
The agent declares victory while the suite is red. A Stop gate holds the line:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# .claude/hooks/quality.py
|
|
78
|
+
from captain_hook import RanCommand, TouchedFile, gate
|
|
79
|
+
|
|
80
|
+
gate(
|
|
81
|
+
"You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
|
|
82
|
+
only_if=[TouchedFile("**/*.py")],
|
|
83
|
+
skip_if=[RanCommand(r"\bpytest\b")],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
|
|
88
|
+
|
|
89
|
+
## More in the docs
|
|
90
|
+
|
|
91
|
+
- **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
|
|
92
|
+
- **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
|
|
93
|
+
- **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
|
|
94
|
+
- **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
|
|
95
|
+
- **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
|
|
96
|
+
- **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
|
|
97
|
+
|
|
98
|
+
Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from cc_transcript.command import Command, CommandLine, Redirect
|
|
3
4
|
from cc_transcript.tools import (
|
|
4
5
|
BashCall,
|
|
5
6
|
EditCall,
|
|
@@ -22,7 +23,6 @@ from cc_transcript.tools import (
|
|
|
22
23
|
from captain_hook import file, style, util
|
|
23
24
|
from captain_hook.app import hook, on
|
|
24
25
|
from captain_hook.ast_grep import COMMENT_TYPES
|
|
25
|
-
from captain_hook.command import CommandLine, ParsedCommand, Redirect
|
|
26
26
|
from captain_hook.conditions import workflow_opt_values, workflow_script_source
|
|
27
27
|
from captain_hook.context import HookContext
|
|
28
28
|
from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
|
|
@@ -30,6 +30,7 @@ from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
|
30
30
|
from captain_hook.events import (
|
|
31
31
|
BaseHookEvent,
|
|
32
32
|
NotificationEvent,
|
|
33
|
+
PermissionRequestEvent,
|
|
33
34
|
PostToolUseEvent,
|
|
34
35
|
PostToolUseFailureEvent,
|
|
35
36
|
PreCompactEvent,
|
|
@@ -40,6 +41,7 @@ from captain_hook.events import (
|
|
|
40
41
|
SubagentStartEvent,
|
|
41
42
|
SubagentStopEvent,
|
|
42
43
|
ToolHookEvent,
|
|
44
|
+
ToolRewriteEvent,
|
|
43
45
|
UserPromptSubmitEvent,
|
|
44
46
|
)
|
|
45
47
|
from captain_hook.fields import Deque
|
|
@@ -48,8 +50,12 @@ from captain_hook.primitives import (
|
|
|
48
50
|
GateVerdict,
|
|
49
51
|
NudgeVerdict,
|
|
50
52
|
PromptCheckVerdict,
|
|
53
|
+
SafetyVerdict,
|
|
54
|
+
approve,
|
|
51
55
|
block_command,
|
|
56
|
+
deny,
|
|
52
57
|
gate,
|
|
58
|
+
llm_approve,
|
|
53
59
|
llm_evaluate,
|
|
54
60
|
llm_gate,
|
|
55
61
|
llm_nudge,
|
|
@@ -73,18 +79,18 @@ from captain_hook.settings import HooksSettings, build_settings
|
|
|
73
79
|
from captain_hook.signals.nlp import Clause, NlpSignal, Phrase, has_nominal_subject, is_past_predicate
|
|
74
80
|
from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
|
|
75
81
|
from captain_hook.tasks import Task, Tasks
|
|
76
|
-
from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
82
|
+
from captain_hook.testing import Allow, Ask, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
77
83
|
from captain_hook.types import (
|
|
78
84
|
Action,
|
|
79
85
|
Agent,
|
|
80
86
|
And,
|
|
81
|
-
Command,
|
|
82
87
|
Content,
|
|
83
88
|
CustomCommandLineCondition,
|
|
84
89
|
CustomCondition,
|
|
85
90
|
CustomInputTypeCondition,
|
|
86
91
|
Event,
|
|
87
92
|
FilePath,
|
|
93
|
+
FromSubagent,
|
|
88
94
|
HookResponse,
|
|
89
95
|
HookResult,
|
|
90
96
|
InPlanMode,
|
|
@@ -96,6 +102,7 @@ from captain_hook.types import (
|
|
|
96
102
|
Runs,
|
|
97
103
|
Signal,
|
|
98
104
|
Signals,
|
|
105
|
+
SkipPermissions,
|
|
99
106
|
SourceEdits,
|
|
100
107
|
TCondition,
|
|
101
108
|
TestFile,
|
|
@@ -40,7 +40,7 @@ HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
|
|
|
40
40
|
VALID_CONDITION_TYPES = tuple(t for t in get_args(TCondition) if t is not CustomCondition)
|
|
41
41
|
VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
|
|
42
42
|
|
|
43
|
-
_TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure
|
|
43
|
+
_TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure | Event.PermissionRequest
|
|
44
44
|
|
|
45
45
|
# Conditions that read the current event's tool input can only match on a tool event; a
|
|
46
46
|
# condition absent from this map (transcript-history conditions, InPlanMode, Waiting,
|
|
@@ -259,7 +259,12 @@ def settings_drift(root: Path) -> set[str]:
|
|
|
259
259
|
def warn_settings_drift(
|
|
260
260
|
output: dict[str, Any] | None, event: Event, root: Path | None, session_dir: Path | None, *, async_: bool
|
|
261
261
|
) -> dict[str, Any] | None:
|
|
262
|
-
if
|
|
262
|
+
if (
|
|
263
|
+
async_
|
|
264
|
+
or root is None
|
|
265
|
+
or session_dir is None
|
|
266
|
+
or event in (Event.Stop | Event.SubagentStop | Event.PermissionRequest)
|
|
267
|
+
):
|
|
263
268
|
return output
|
|
264
269
|
marker = session_dir / ".drift_surfaced"
|
|
265
270
|
if marker.exists():
|
|
@@ -16,6 +16,7 @@ from captain_hook.types import (
|
|
|
16
16
|
Content,
|
|
17
17
|
CustomCondition,
|
|
18
18
|
FilePath,
|
|
19
|
+
FromSubagent,
|
|
19
20
|
InPlanMode,
|
|
20
21
|
Not,
|
|
21
22
|
Or,
|
|
@@ -23,6 +24,7 @@ from captain_hook.types import (
|
|
|
23
24
|
RanCommand,
|
|
24
25
|
ReadFile,
|
|
25
26
|
Runs,
|
|
27
|
+
SkipPermissions,
|
|
26
28
|
SourceEdits,
|
|
27
29
|
TCondition,
|
|
28
30
|
TestFile,
|
|
@@ -228,8 +230,8 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
228
230
|
return has_read_glob(evt.ctx.transcript, *patterns, subagents=subagents)
|
|
229
231
|
case TouchedFile(patterns, subagents):
|
|
230
232
|
return evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
|
|
231
|
-
case RanCommand(
|
|
232
|
-
return evt.ctx.transcript.has_command(
|
|
233
|
+
case RanCommand(argv, subagents):
|
|
234
|
+
return evt.ctx.transcript.has_command(*argv, subagents=subagents)
|
|
233
235
|
case Runs(argv):
|
|
234
236
|
return bool(argv and (cl := evt.command_line) and any(cmd.argv[: len(argv)] == argv for cmd in cl.commands))
|
|
235
237
|
case InPlanMode():
|
|
@@ -239,6 +241,10 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
239
241
|
)
|
|
240
242
|
case Waiting():
|
|
241
243
|
return is_waiting(evt)
|
|
244
|
+
case FromSubagent():
|
|
245
|
+
return evt.is_subagent
|
|
246
|
+
case SkipPermissions():
|
|
247
|
+
return evt.skip_permissions
|
|
242
248
|
case And(conditions):
|
|
243
249
|
return all(check_condition(sub, evt) for sub in conditions)
|
|
244
250
|
case Not(condition):
|
|
@@ -7,7 +7,8 @@ from functools import cached_property
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
9
9
|
|
|
10
|
-
from cc_transcript.activity import SessionActivity
|
|
10
|
+
from cc_transcript.activity import SessionActivity
|
|
11
|
+
from cc_transcript.filterspec import event_meta
|
|
11
12
|
from cc_transcript.ids import SessionId
|
|
12
13
|
from cc_transcript.models import AssistantEvent, ToolUseBlock
|
|
13
14
|
from cc_transcript.parser import parse_events_from_bytes
|
|
@@ -84,7 +85,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
|
|
|
84
85
|
events=events,
|
|
85
86
|
)
|
|
86
87
|
session_id = next(
|
|
87
|
-
(meta.session_id for event in events if (meta :=
|
|
88
|
+
(meta.session_id for event in events if (meta := event_meta(event)) is not None),
|
|
88
89
|
SessionId(path.stem if path else "unknown"),
|
|
89
90
|
)
|
|
90
91
|
return Session.from_activity(
|
|
@@ -67,10 +67,25 @@ def execute_hook(
|
|
|
67
67
|
return result
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def format_permission_decision(result: HookResult) -> dict[str, Any] | None:
|
|
71
|
+
match result.action:
|
|
72
|
+
case Action.allow:
|
|
73
|
+
decision: dict[str, Any] = {"behavior": "allow"}
|
|
74
|
+
case Action.rewrite:
|
|
75
|
+
decision = {"behavior": "allow", "updatedInput": result.updated_input}
|
|
76
|
+
case Action.block:
|
|
77
|
+
decision = {"behavior": "deny"} | ({"message": result.message} if result.message else {})
|
|
78
|
+
case Action.warn:
|
|
79
|
+
return None
|
|
80
|
+
return {"hookSpecificOutput": {"hookEventName": Event.PermissionRequest.name, "decision": decision}}
|
|
81
|
+
|
|
82
|
+
|
|
70
83
|
def format_output(event: Event, result: HookResult) -> dict[str, Any] | None:
|
|
71
84
|
"""Render a ``HookResult`` as the JSON envelope Claude Code expects on stdout for *event*."""
|
|
72
85
|
if event in (Event.Stop | Event.SubagentStop):
|
|
73
86
|
return {"decision": "block", "reason": result.message} if result.action is not Action.allow else None
|
|
87
|
+
if event is Event.PermissionRequest:
|
|
88
|
+
return format_permission_decision(result)
|
|
74
89
|
|
|
75
90
|
match result.action:
|
|
76
91
|
case Action.block:
|
|
@@ -25,10 +25,10 @@ from captain_hook.types import Event
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from collections.abc import Callable
|
|
27
27
|
|
|
28
|
+
from cc_transcript.command import CommandLine as CommandLineType
|
|
28
29
|
from cc_transcript.ids import ToolDigest
|
|
29
30
|
from cc_transcript.tools import ToolCall
|
|
30
31
|
|
|
31
|
-
from captain_hook.command import CommandLine as CommandLineType
|
|
32
32
|
from captain_hook.context import HookContext
|
|
33
33
|
from captain_hook.tasks import Tasks
|
|
34
34
|
from captain_hook.types import HookResult
|
|
@@ -81,6 +81,18 @@ class BaseHookEvent:
|
|
|
81
81
|
|
|
82
82
|
return T.for_session(self.session_id)
|
|
83
83
|
|
|
84
|
+
@cached_property
|
|
85
|
+
def skip_permissions(self) -> bool:
|
|
86
|
+
"""Whether the session's ``claude`` process was launched with permission bypass available.
|
|
87
|
+
|
|
88
|
+
Walks the process tree to the nearest ``claude`` ancestor and reports whether that
|
|
89
|
+
process was started with ``--dangerously-skip-permissions`` or
|
|
90
|
+
``--allow-dangerously-skip-permissions``.
|
|
91
|
+
"""
|
|
92
|
+
from captain_hook.util.proc import claude_skip_permissions
|
|
93
|
+
|
|
94
|
+
return claude_skip_permissions()
|
|
95
|
+
|
|
84
96
|
@property
|
|
85
97
|
def user_prompt(self) -> str | None:
|
|
86
98
|
return self._raw.get("prompt")
|
|
@@ -125,7 +137,7 @@ class BaseHookEvent:
|
|
|
125
137
|
def command(self) -> str | None:
|
|
126
138
|
return self.input.command if isinstance(self.input, BashCall) else None
|
|
127
139
|
|
|
128
|
-
@
|
|
140
|
+
@property
|
|
129
141
|
def command_line(self) -> CommandLineType | None:
|
|
130
142
|
return None
|
|
131
143
|
|
|
@@ -213,11 +225,11 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
213
225
|
def _tool_input(self) -> dict[str, Any]:
|
|
214
226
|
return self._raw.get("tool_input", {})
|
|
215
227
|
|
|
216
|
-
@
|
|
228
|
+
@property
|
|
217
229
|
def command_line(self) -> CommandLineType | None:
|
|
218
|
-
from
|
|
230
|
+
from cc_transcript.command import parse_command_line
|
|
219
231
|
|
|
220
|
-
return
|
|
232
|
+
return parse_command_line(cmd) if (cmd := self.command) else None
|
|
221
233
|
|
|
222
234
|
@property
|
|
223
235
|
def content(self) -> str | None:
|
|
@@ -267,9 +279,9 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
267
279
|
return call.agent_type if isinstance(call := self.input, TaskCall) else None
|
|
268
280
|
|
|
269
281
|
def command_matches(self, *patterns: str) -> bool:
|
|
270
|
-
if not (cl := self.command_line):
|
|
282
|
+
if not (cl := self.command_line) or (cmd := cl.primary) is None:
|
|
271
283
|
return False
|
|
272
|
-
return any(
|
|
284
|
+
return any(cmd.matches(p) for p in patterns)
|
|
273
285
|
|
|
274
286
|
def file_matches(self, *globs: str) -> bool:
|
|
275
287
|
return bool(self.file) and self.file.matches(*globs)
|
|
@@ -279,10 +291,12 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
279
291
|
|
|
280
292
|
|
|
281
293
|
@dataclass
|
|
282
|
-
class
|
|
283
|
-
"""
|
|
294
|
+
class ToolRewriteEvent(ToolHookEvent):
|
|
295
|
+
"""Tool event whose input can be rewritten before it runs.
|
|
284
296
|
|
|
285
|
-
|
|
297
|
+
Base for the events where Claude Code accepts an ``updatedInput`` decision
|
|
298
|
+
(``PreToolUse`` and ``PermissionRequest``), adding the ``rewrite*`` helpers.
|
|
299
|
+
"""
|
|
286
300
|
|
|
287
301
|
def rewrite(self, updated_input: dict[str, Any], *, note: str | None = None) -> HookResult:
|
|
288
302
|
"""Allow the tool but replace its input with ``updated_input`` (Claude Code's ``updatedInput``).
|
|
@@ -322,6 +336,33 @@ class PreToolUseEvent(ToolHookEvent):
|
|
|
322
336
|
return self.rewrite(updated, note=note) if updated != base else None
|
|
323
337
|
|
|
324
338
|
|
|
339
|
+
@dataclass
|
|
340
|
+
class PreToolUseEvent(ToolRewriteEvent):
|
|
341
|
+
"""Fires before a tool is executed. Return a block result to prevent execution."""
|
|
342
|
+
|
|
343
|
+
event_name: ClassVar[Event] = Event.PreToolUse
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class PermissionRequestEvent(ToolRewriteEvent):
|
|
348
|
+
"""Fires when a permission dialog would be shown.
|
|
349
|
+
|
|
350
|
+
``allow``/``block``/``rewrite`` answer the dialog (block maps to a deny with the
|
|
351
|
+
message shown to the user); ``None`` and ``warn`` fall through, so the dialog
|
|
352
|
+
shows normally.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
event_name: ClassVar[Event] = Event.PermissionRequest
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def permission_suggestions(self) -> list[dict[str, Any]]:
|
|
359
|
+
return self._raw.get("permission_suggestions", [])
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def agent_type(self) -> str | None:
|
|
363
|
+
return self._raw.get("agent_type")
|
|
364
|
+
|
|
365
|
+
|
|
325
366
|
@dataclass
|
|
326
367
|
class PostToolUseEvent(ToolHookEvent):
|
|
327
368
|
"""Fires after a tool completes successfully, with access to the tool response."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Workaround for https://github.com/anthropics/claude-code/issues/73176: in-process
|
|
2
|
+
# teammates don't inherit the leader's --dangerously-skip-permissions consent, so their
|
|
3
|
+
# Bash calls (heredocs, redirects) pop permission dialogs in the lead UI. Auto-approve
|
|
4
|
+
# native teammate Bash only — MCP tools named mcp__<srv>__Bash suffix-match Tool("Bash")
|
|
5
|
+
# and are vetoed — and only when the process tree shows the user launched with the flag;
|
|
6
|
+
# the denylist is a courtesy speed bump (raw-text, over-prompting by design), not a
|
|
7
|
+
# security boundary — the session is already bypass-consented.
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from captain_hook import (
|
|
11
|
+
Allow,
|
|
12
|
+
Ask,
|
|
13
|
+
BaseHookEvent,
|
|
14
|
+
CustomCondition,
|
|
15
|
+
FromSubagent,
|
|
16
|
+
Input,
|
|
17
|
+
SkipPermissions,
|
|
18
|
+
Tool,
|
|
19
|
+
ToolInput,
|
|
20
|
+
approve,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class McpTool(CustomCondition):
|
|
25
|
+
"""Matches MCP-server tools (``mcp__<server>__<tool>``), which Tool() suffix-matching also accepts."""
|
|
26
|
+
|
|
27
|
+
def check(self, evt: BaseHookEvent) -> bool:
|
|
28
|
+
return bool(evt.tool_name) and evt.tool_name.startswith("mcp__")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
approve(
|
|
32
|
+
"teammate bash under skip-permissions",
|
|
33
|
+
only_if=[Tool("Bash"), ToolInput("command", r"[\s\S]"), FromSubagent(), SkipPermissions()],
|
|
34
|
+
skip_if=[
|
|
35
|
+
McpTool(),
|
|
36
|
+
ToolInput(
|
|
37
|
+
"command",
|
|
38
|
+
r"\b(rm|dd|shred|truncate|sudo|mkfs[.\w]*)\b"
|
|
39
|
+
r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*(reset|clean|restore)\b"
|
|
40
|
+
r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*push\b[^\n]*(\s--?force(-with-lease)?\b|\s--delete\b)"
|
|
41
|
+
r"|\b(curl|wget)\b[^|\n]*\|\s*((\S*/)?env\s+)?(\S*/)?(ba|z|da)?sh\b",
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
tests={
|
|
45
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
46
|
+
Input(command="echo 'x = 1' > /tmp/conf.py", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
47
|
+
Input(command="git status", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
48
|
+
Input(command="git -C . log", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
49
|
+
Input(command="rm -rf build", agent_id="tm1", skip_permissions=True): Ask(),
|
|
50
|
+
Input(command="sudo systemsetup -setremotelogin on", agent_id="tm1", skip_permissions=True): Ask(),
|
|
51
|
+
Input(command="git reset --hard HEAD~1", agent_id="tm1", skip_permissions=True): Ask(),
|
|
52
|
+
Input(command="git -C . reset --hard", agent_id="tm1", skip_permissions=True): Ask(),
|
|
53
|
+
Input(command="git --no-pager clean -fd", agent_id="tm1", skip_permissions=True): Ask(),
|
|
54
|
+
Input(command="curl https://get.x.sh | sh", agent_id="tm1", skip_permissions=True): Ask(),
|
|
55
|
+
Input(command="curl https://get.x.sh | /usr/bin/env bash", agent_id="tm1", skip_permissions=True): Ask(),
|
|
56
|
+
Input(command="git push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
|
|
57
|
+
Input(command="git -C repo push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
|
|
58
|
+
Input(tool="mcp__srv__Bash", tool_input={"cmd": "x"}, agent_id="tm1", skip_permissions=True): Ask(),
|
|
59
|
+
Input(
|
|
60
|
+
tool="mcp__srv__Bash", tool_input={"command": "echo hi"}, agent_id="tm1", skip_permissions=True
|
|
61
|
+
): Ask(), # MCP Bash, benign command — never auto-approved
|
|
62
|
+
Input(tool="mcp__ops__Bash", tool_input={"command": "rm -rf /"}, agent_id="tm1", skip_permissions=True): Ask(),
|
|
63
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", skip_permissions=True): Ask(), # main thread
|
|
64
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=False): Ask(), # no consent
|
|
65
|
+
},
|
|
66
|
+
)
|