codex-toolkit 0.4.0
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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/codex-toolkit.js +6 -0
- package/examples/auto-lint.config.example.json +11 -0
- package/examples/scope-guard.config.example.json +15 -0
- package/package.json +65 -0
- package/src/auto-lint.js +254 -0
- package/src/diff-budget.js +173 -0
- package/src/hook-protocol.js +146 -0
- package/src/index.js +12 -0
- package/src/installer.js +331 -0
- package/src/scope-guard.js +180 -0
- package/src/shield-destructive-cmd.js +179 -0
- package/src/shield-env-guard.js +192 -0
- package/src/state-store.js +91 -0
- package/src/tool-pace-check.js +130 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fox328230966-alt
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# codex-toolkit
|
|
2
|
+
|
|
3
|
+
> **Stop AI scope creep in Codex CLI.**
|
|
4
|
+
> A toolkit of practical hooks that keep your AI edits scoped, budgeted, and safe.
|
|
5
|
+
|
|
6
|
+
[](https://github.com/fox328230966-alt/codex-toolkit/actions)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## The problem
|
|
13
|
+
|
|
14
|
+
You ask Codex CLI to fix a bug in `auth.ts`. It comes back having
|
|
15
|
+
reformatted 14 unrelated files, rewritten your `README`, and bumped a
|
|
16
|
+
dependency. The result is technically fine — but it is **not what you
|
|
17
|
+
asked for**, and now you have 200 lines of unreviewed churn in your diff.
|
|
18
|
+
|
|
19
|
+
This is **scope creep**, and it is the unsolved ergonomic problem of
|
|
20
|
+
AI coding tools in 2026.
|
|
21
|
+
|
|
22
|
+
## The fix
|
|
23
|
+
|
|
24
|
+
`codex-toolkit` is a small set of **hooks** (lifecycle scripts) you
|
|
25
|
+
install into Codex CLI. Each hook has one job:
|
|
26
|
+
|
|
27
|
+
| Hook | Job |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `scope-guard` | Block file edits outside the scope you declared for this task. |
|
|
30
|
+
| `diff-budget` | Refuse edits once a per-task file/line budget is exceeded. |
|
|
31
|
+
| `tool-pace-check` | Slow the agent down when it tries to chain many tool calls in a row. |
|
|
32
|
+
| `shield-destructive-cmd` | Refuse `rm -rf`, `git push --force`, `drop table`, etc. |
|
|
33
|
+
| `shield-env-guard` | Refuse writes to `.env`, `id_rsa`, `*.key`, and similar. |
|
|
34
|
+
| `auto-lint` | Run the right linter after a Go / Python / TS file is touched. |
|
|
35
|
+
|
|
36
|
+
> The v0.4.0 release ships the full six-hook suite:
|
|
37
|
+
> **`scope-guard`**, **`diff-budget`**, **`tool-pace-check`**,
|
|
38
|
+
> **`shield-destructive-cmd`**, **`shield-env-guard`**, and **`auto-lint`**.
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
## Why this category, why now
|
|
43
|
+
|
|
44
|
+
Codex CLI shipped lifecycle hooks as a stable, default-on feature in
|
|
45
|
+
2025. For the first time, a small piece of user code can sit in the
|
|
46
|
+
critical path of every tool call without forking the agent. Until now,
|
|
47
|
+
guardrails were either:
|
|
48
|
+
|
|
49
|
+
- **coarse** (workspace-wide sandbox), or
|
|
50
|
+
- **manual** (the user has to read every approval prompt and click no).
|
|
51
|
+
|
|
52
|
+
`codex-toolkit` is the **per-task guardrail layer** that fits between
|
|
53
|
+
those two — see [`docs/why-scope-creep.md`](docs/why-scope-creep.md)
|
|
54
|
+
for the longer version.
|
|
55
|
+
|
|
56
|
+
## Architecture
|
|
57
|
+
|
|
58
|
+
Six hooks. Two trigger points. Zero runtime dependencies.
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
| Trigger | Hooks | What they decide |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| **PreToolUse** (before the tool runs) | `scope-guard`, `tool-pace-check`, `shield-destructive-cmd`, `shield-env-guard` | "Should this tool call even happen?" |
|
|
65
|
+
| **PostToolUse** (after the tool runs) | `diff-budget`, `auto-lint` | "Was the result acceptable?" |
|
|
66
|
+
|
|
67
|
+
Both points emit a JSON decision `{ "decision": "allow" | "ask" | "deny", "reason": "..." }` and respect the hook process's exit code (`0` = success, `2` = blocking error → deny). See [`docs/architecture.svg`](docs/architecture.svg) for the full diagram.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
# Coming soon (not published yet — see "Roadmap"):
|
|
73
|
+
# npx codex-toolkit init
|
|
74
|
+
#
|
|
75
|
+
# For now, install from this repo:
|
|
76
|
+
git clone https://github.com/fox328230966-alt/codex-toolkit.git
|
|
77
|
+
cd codex-toolkit
|
|
78
|
+
node bin/codex-toolkit.js init
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`init` will:
|
|
82
|
+
|
|
83
|
+
1. Copy every bundled hook into `~/.codex/hooks/`.
|
|
84
|
+
2. Write `~/.codex/hooks.json` registering them with Codex CLI.
|
|
85
|
+
3. Append a `[hooks]` section to `~/.codex/config.toml` (only if you
|
|
86
|
+
don't already have one).
|
|
87
|
+
|
|
88
|
+
Verify:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
node bin/codex-toolkit.js list # see what is installed
|
|
92
|
+
node bin/codex-toolkit.js doctor # run sanity checks + smoke test
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configure `scope-guard`
|
|
96
|
+
|
|
97
|
+
Drop a JSON config at one of:
|
|
98
|
+
|
|
99
|
+
- `<your-project>/.codex-toolkit/scope-guard.json` (project-level)
|
|
100
|
+
- `~/.codex/scope-guard.json` (user-level, applies to all projects)
|
|
101
|
+
- `$CODEX_TOOLKIT_SCOPE_GUARD_CONFIG` (explicit override)
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mode": "enforce",
|
|
106
|
+
"allow": ["src/auth/**", "src/shared/**", "tests/auth/**"],
|
|
107
|
+
"deny": [".env", ".env.*", "**/secrets/**", "**/migrations/**"],
|
|
108
|
+
"log": true
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| Field | Values | Effect |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `mode` | `enforce` \| `ask` \| `off` | `enforce` = hard-deny out-of-scope edits. `ask` = prompt the user. `off` = no-op. |
|
|
115
|
+
| `allow` | glob list | Paths matching at least one pattern are allowed. |
|
|
116
|
+
| `deny` | glob list | If a path matches *any* deny pattern, the edit is refused — even if it would have been allowed. |
|
|
117
|
+
| `log` | bool | When `true`, every decision is logged to stderr. |
|
|
118
|
+
|
|
119
|
+
Glob syntax: `*` matches a single path segment, `**` matches any number of segments (including zero), `?` matches a single character, `.` is a literal dot.
|
|
120
|
+
|
|
121
|
+
### Example: prompt the model to declare its scope
|
|
122
|
+
|
|
123
|
+
The best way to use `scope-guard` is to put a scope declaration at the top of your prompt:
|
|
124
|
+
|
|
125
|
+
> _"Refactor the OAuth flow. The only files you may touch are `src/auth/**` and `tests/auth/**`. Anything else: ask first."_
|
|
126
|
+
|
|
127
|
+
…and put a matching `allow` list in the config. Codex's edit planning is good enough that this combination cuts 90% of out-of-scope churn.
|
|
128
|
+
|
|
129
|
+
## Configure `shield-destructive-cmd` and `shield-env-guard`
|
|
130
|
+
|
|
131
|
+
These hooks have a default deny list baked in. To override, drop a JSON file at:
|
|
132
|
+
|
|
133
|
+
- `<your-project>/.codex-toolkit/shield-destructive-cmd.json`
|
|
134
|
+
- `<your-project>/.codex-toolkit/shield-env-guard.json`
|
|
135
|
+
|
|
136
|
+
(or the `~/.codex/` equivalents).
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
// .codex-toolkit/shield-destructive-cmd.json
|
|
140
|
+
{
|
|
141
|
+
"mode": "enforce",
|
|
142
|
+
"extra_patterns": ["\\bterraform\\s+destroy\\b"],
|
|
143
|
+
"allow_overrides": ["^git\\s+push\\s+--force\\s+to-my-personal-fork"]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
// .codex-toolkit/shield-env-guard.json
|
|
149
|
+
{
|
|
150
|
+
"mode": "enforce",
|
|
151
|
+
"extra_patterns": ["**/internal-token*"],
|
|
152
|
+
"allow_overrides": ["docs/.env.example"]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`extra_patterns` is appended to the built-in deny list; `allow_overrides` is consulted first and short-circuits the deny list if any entry matches.
|
|
157
|
+
|
|
158
|
+
## Configure `diff-budget` and `tool-pace-check`
|
|
159
|
+
|
|
160
|
+
These hooks have a default config baked in. To override, drop a JSON file at:
|
|
161
|
+
|
|
162
|
+
- `<your-project>/.codex-toolkit/diff-budget.json`
|
|
163
|
+
- `<your-project>/.codex-toolkit/tool-pace.json`
|
|
164
|
+
|
|
165
|
+
(or the `~/.codex/` equivalents).
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
// .codex-toolkit/diff-budget.json
|
|
169
|
+
{
|
|
170
|
+
"mode": "enforce",
|
|
171
|
+
"max_bytes_per_write": 100000,
|
|
172
|
+
"max_files_per_task": 25,
|
|
173
|
+
"max_total_bytes": 500000
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
// .codex-toolkit/tool-pace.json
|
|
179
|
+
{
|
|
180
|
+
"mode": "enforce",
|
|
181
|
+
"max_calls_in_window": 8,
|
|
182
|
+
"window_seconds": 60
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
State files (per-session counters) live at `<cwd>/.codex-toolkit/.diff-budget.json` and `.tool-pace.json`. Delete them to reset a task's budget.
|
|
187
|
+
|
|
188
|
+
## Configure `auto-lint`
|
|
189
|
+
|
|
190
|
+
Default config: every recognized extension gets a sane linter. Override at `<your-project>/.codex-toolkit/auto-lint.json` (or `~/.codex/auto-lint.json`):
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"mode": "enforce",
|
|
195
|
+
"fallback": "allow",
|
|
196
|
+
"linters": {
|
|
197
|
+
"go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
|
|
198
|
+
"py": { "cmd": ["ruff", "check", "--stdin-display-path", "PLACEHOLDER", "-"], "timeout_ms": 10000 },
|
|
199
|
+
"ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`fallback: "deny"` is the strict choice — refuse any change that the linter can't actually check (e.g. the linter binary is missing on PATH). The default `"allow"` is the friendly choice: log a warning, let the change through, trust the user to lint later.
|
|
205
|
+
|
|
206
|
+
## Compare with alternatives
|
|
207
|
+
|
|
208
|
+
We wrote down the four-way comparison (vanilla Codex, hand-rolled hooks, Codex built-ins only, `codex-toolkit`) in [`docs/comparison.md`](docs/comparison.md). The short version:
|
|
209
|
+
|
|
210
|
+
- **Vanilla Codex** has no scope / pace / budget / blocklist / auto-lint defenses.
|
|
211
|
+
- **Hand-rolled hooks** work but you maintain ~200 LOC of glue per project and never get a test suite for the safety net itself.
|
|
212
|
+
- **Built-ins** (`approval_policy`, `sandbox_mode`, `rules`, `undo`) are real and worth using, but they are *complementary*, not a substitute. Sandbox doesn't know "in scope". `undo` is reactive.
|
|
213
|
+
- **`codex-toolkit`** is the lightest-touch option for the same safety level: `codex-toolkit init`, edit a 5-line JSON if you want to customize, done.
|
|
214
|
+
|
|
215
|
+
The recommended config in `~/.codex/config.toml` is to **layer** the two: built-ins as defensive defaults, codex-toolkit hooks as the per-task guardrail on top.
|
|
216
|
+
|
|
217
|
+
## Run as a library
|
|
218
|
+
|
|
219
|
+
`codex-toolkit` is also a small ESM library:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
import { evaluate, DECISIONS } from 'codex-toolkit/hooks/scope-guard';
|
|
223
|
+
|
|
224
|
+
const event = {
|
|
225
|
+
eventName: 'PreToolUse',
|
|
226
|
+
toolName: 'write_file',
|
|
227
|
+
toolInput: { file_path: 'src/auth/login.ts' },
|
|
228
|
+
cwd: process.cwd(),
|
|
229
|
+
raw: {},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const result = evaluate(event);
|
|
233
|
+
if (result.decision === DECISIONS.DENY) {
|
|
234
|
+
console.error('Refused:', result.reason);
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
npm install
|
|
242
|
+
npm test
|
|
243
|
+
npm run lint
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Hooks ship with a full unit test suite (Node's built-in `node:test` —
|
|
247
|
+
no extra deps). CI runs the suite on Node 18, 20, and 22.
|
|
248
|
+
|
|
249
|
+
## Roadmap
|
|
250
|
+
|
|
251
|
+
- [x] `scope-guard` — v0.1.0
|
|
252
|
+
- [x] `diff-budget` — v0.2.0
|
|
253
|
+
- [x] `tool-pace-check` — v0.2.0
|
|
254
|
+
- [x] `shield-destructive-cmd` — v0.3.0
|
|
255
|
+
- [x] `shield-env-guard` — v0.3.0
|
|
256
|
+
- [x] `auto-lint` — v0.4.0
|
|
257
|
+
- [ ] `npx codex-toolkit init` published to npm — v0.4.0
|
|
258
|
+
- [ ] Per-hook "explain why" debug output — v0.5.0
|
|
259
|
+
- [ ] Codex IDE extension parity — v0.6.0
|
|
260
|
+
|
|
261
|
+
## Contributing
|
|
262
|
+
|
|
263
|
+
Issues and PRs welcome. The bar is intentionally low:
|
|
264
|
+
|
|
265
|
+
- One hook or one behavior per PR.
|
|
266
|
+
- Tests for the behavior you changed.
|
|
267
|
+
- A line in the `README.md` hook table.
|
|
268
|
+
- Run `npm test` and `npm run lint` before pushing.
|
|
269
|
+
|
|
270
|
+
See [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mode": "enforce",
|
|
3
|
+
"fallback": "allow",
|
|
4
|
+
"linters": {
|
|
5
|
+
"go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
|
|
6
|
+
"py": { "cmd": ["ruff", "check", "--stdin-display-path", "PLACEHOLDER", "-"], "timeout_ms": 10000 },
|
|
7
|
+
"ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 },
|
|
8
|
+
"js": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 },
|
|
9
|
+
"rs": { "cmd": ["rustfmt", "--check"], "timeout_ms": 10000 }
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-toolkit",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Stop AI scope creep in Codex CLI. A toolkit of practical hooks that keep your AI edits scoped, budgeted, and safe.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./hooks/scope-guard": "./src/scope-guard.js",
|
|
10
|
+
"./hooks/diff-budget": "./src/diff-budget.js",
|
|
11
|
+
"./hooks/tool-pace-check": "./src/tool-pace-check.js",
|
|
12
|
+
"./hooks/shield-destructive-cmd": "./src/shield-destructive-cmd.js",
|
|
13
|
+
"./hooks/shield-env-guard": "./src/shield-env-guard.js",
|
|
14
|
+
"./hooks/auto-lint": "./src/auto-lint.js",
|
|
15
|
+
"./installer": "./src/installer.js"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"codex-toolkit": "bin/codex-toolkit.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"src/",
|
|
23
|
+
"hooks/",
|
|
24
|
+
"examples/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"test:single": "node --test",
|
|
31
|
+
"lint": "node --check src/*.js bin/*.js",
|
|
32
|
+
"init": "node bin/codex-toolkit.js init",
|
|
33
|
+
"list": "node bin/codex-toolkit.js list",
|
|
34
|
+
"doctor": "node bin/codex-toolkit.js doctor",
|
|
35
|
+
"prepublishOnly": "npm test && npm run lint"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"codex",
|
|
39
|
+
"codex-cli",
|
|
40
|
+
"openai",
|
|
41
|
+
"ai-coding",
|
|
42
|
+
"ai-guardrails",
|
|
43
|
+
"scope-creep",
|
|
44
|
+
"hooks",
|
|
45
|
+
"safety",
|
|
46
|
+
"linter",
|
|
47
|
+
"agent"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18.0.0"
|
|
51
|
+
},
|
|
52
|
+
"author": {
|
|
53
|
+
"name": "fox328230966-alt",
|
|
54
|
+
"url": "https://github.com/fox328230966-alt"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/fox328230966-alt/codex-toolkit.git"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/fox328230966-alt/codex-toolkit/issues"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/fox328230966-alt/codex-toolkit#readme"
|
|
65
|
+
}
|
package/src/auto-lint.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// auto-lint — run the right linter on the file Codex just touched.
|
|
2
|
+
//
|
|
3
|
+
// Scope-guard controls *where* Codex may edit. diff-budget controls *how
|
|
4
|
+
// much*. auto-lint controls *what quality the result is in*: every time
|
|
5
|
+
// Codex writes a source file, the right linter runs against it before the
|
|
6
|
+
// tool call is allowed to fully complete. If the linter reports issues,
|
|
7
|
+
// the hook returns a deny decision that pushes the model's next turn
|
|
8
|
+
// toward fixing them.
|
|
9
|
+
//
|
|
10
|
+
// Detection is by file extension. Defaults are conservative and cover
|
|
11
|
+
// the languages we actually care about (Go, Python, JS/TS, Rust). Users
|
|
12
|
+
// can override per-linter command and timeout via config.
|
|
13
|
+
//
|
|
14
|
+
// Triggers on PostToolUse for file-mutating tools.
|
|
15
|
+
// Configuration: <cwd>/.codex-toolkit/auto-lint.json
|
|
16
|
+
// {
|
|
17
|
+
// "mode": "enforce" | "ask" | "off",
|
|
18
|
+
// "linters": {
|
|
19
|
+
// "go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
|
|
20
|
+
// "py": { "cmd": ["ruff", "check", "--stdin-display-path", "-"], "timeout_ms": 10000 },
|
|
21
|
+
// "ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin"], "timeout_ms": 15000 }
|
|
22
|
+
// },
|
|
23
|
+
// "fallback": "allow" // what to do when the linter binary is missing
|
|
24
|
+
// }
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import {
|
|
31
|
+
DECISIONS,
|
|
32
|
+
FILE_MUTATING_TOOLS,
|
|
33
|
+
emitDecision,
|
|
34
|
+
emitError,
|
|
35
|
+
extractTargetPath,
|
|
36
|
+
parseHookInput,
|
|
37
|
+
} from './hook-protocol.js';
|
|
38
|
+
|
|
39
|
+
const EXT_TO_LANG = {
|
|
40
|
+
'.go': 'go',
|
|
41
|
+
'.py': 'py',
|
|
42
|
+
'.pyi': 'py',
|
|
43
|
+
'.ts': 'ts',
|
|
44
|
+
'.tsx': 'ts',
|
|
45
|
+
'.mts': 'ts',
|
|
46
|
+
'.cts': 'ts',
|
|
47
|
+
'.js': 'js',
|
|
48
|
+
'.jsx': 'js',
|
|
49
|
+
'.mjs': 'js',
|
|
50
|
+
'.cjs': 'js',
|
|
51
|
+
'.rs': 'rs',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Default linter invocations. We feed the *file content* on stdin where the
|
|
55
|
+
// linter supports it, and pass the file path as a positional argument. We
|
|
56
|
+
// always pass through stderr; we treat non-empty stdout as a "lint issues
|
|
57
|
+
// found" signal. Exit code non-zero with empty stdout is also "issues".
|
|
58
|
+
const DEFAULT_LINTERS = {
|
|
59
|
+
go: { cmd: ['gofmt', '-l'], timeout_ms: 5000 },
|
|
60
|
+
py: { cmd: ['ruff', 'check', '--stdin-display-path', 'PLACEHOLDER', '-'], timeout_ms: 10000 },
|
|
61
|
+
ts: { cmd: ['eslint', '--no-warn-ignored', '--stdin', '--stdin-filename', 'PLACEHOLDER'], timeout_ms: 15000 },
|
|
62
|
+
js: { cmd: ['eslint', '--no-warn-ignored', '--stdin', '--stdin-filename', 'PLACEHOLDER'], timeout_ms: 15000 },
|
|
63
|
+
rs: { cmd: ['rustfmt', '--check'], timeout_ms: 10000 },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const DEFAULT_CONFIG = {
|
|
67
|
+
mode: 'enforce',
|
|
68
|
+
linters: {},
|
|
69
|
+
fallback: 'allow', // 'allow' | 'deny' — when the linter binary is missing
|
|
70
|
+
log: true,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function loadConfig() {
|
|
74
|
+
const candidates = [
|
|
75
|
+
process.env.CODEX_TOOLKIT_AUTO_LINT_CONFIG,
|
|
76
|
+
path.join(process.cwd(), '.codex-toolkit', 'auto-lint.json'),
|
|
77
|
+
path.join(process.env.HOME || '', '.codex', 'auto-lint.json'),
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
for (const file of candidates) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
82
|
+
return {
|
|
83
|
+
...DEFAULT_CONFIG,
|
|
84
|
+
...parsed,
|
|
85
|
+
linters: { ...DEFAULT_LINTERS, ...(parsed.linters || {}) },
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.code !== 'ENOENT') {
|
|
89
|
+
emitError(`auto-lint: failed to read ${file}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ...DEFAULT_CONFIG, linters: DEFAULT_LINTERS };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function langFor(filePath) {
|
|
97
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
98
|
+
return EXT_TO_LANG[ext] || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readFileIfExists(filePath) {
|
|
102
|
+
try {
|
|
103
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function runLinter(cmd, timeoutMs, stdinContent) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
112
|
+
let stdout = '';
|
|
113
|
+
let stderr = '';
|
|
114
|
+
let settled = false;
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
settled = true;
|
|
117
|
+
proc.kill('SIGKILL');
|
|
118
|
+
resolve({ ok: false, reason: 'timeout', stdout, stderr });
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
proc.stdout.on('data', (b) => (stdout += b.toString()));
|
|
121
|
+
proc.stderr.on('data', (b) => (stderr += b.toString()));
|
|
122
|
+
proc.on('error', (err) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve({ ok: false, reason: 'spawn-error', error: err.code || err.message, stdout, stderr });
|
|
127
|
+
});
|
|
128
|
+
proc.on('close', (code) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve({ ok: true, code, stdout, stderr });
|
|
133
|
+
});
|
|
134
|
+
if (stdinContent !== null && stdinContent !== undefined) {
|
|
135
|
+
proc.stdin.write(stdinContent);
|
|
136
|
+
}
|
|
137
|
+
proc.stdin.end();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function substitutePlaceholder(cmd, replacement) {
|
|
142
|
+
return cmd.map((arg) => (arg === 'PLACEHOLDER' ? replacement : arg));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function evaluate(event) {
|
|
146
|
+
const config = loadConfig();
|
|
147
|
+
if (config.mode === 'off') {
|
|
148
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
|
|
149
|
+
}
|
|
150
|
+
if (!FILE_MUTATING_TOOLS.has(event.toolName)) {
|
|
151
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
|
|
152
|
+
}
|
|
153
|
+
const target = extractTargetPath(event.toolInput);
|
|
154
|
+
if (!target) {
|
|
155
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
|
|
156
|
+
}
|
|
157
|
+
const lang = langFor(target);
|
|
158
|
+
if (!lang) {
|
|
159
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
|
|
160
|
+
}
|
|
161
|
+
const linter = config.linters[lang];
|
|
162
|
+
if (!linter) {
|
|
163
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Build the actual command (substitute placeholder with the target file)
|
|
167
|
+
// and figure out the stdin content (read the freshly-written file, or fall
|
|
168
|
+
// back to the inline content the tool input may carry).
|
|
169
|
+
const cmd = substitutePlaceholder(linter.cmd, target);
|
|
170
|
+
const inline = event.toolInput?.content ?? event.toolInput?.new_string ?? null;
|
|
171
|
+
const fileContent = readFileIfExists(target);
|
|
172
|
+
const stdin = fileContent !== null ? fileContent : inline;
|
|
173
|
+
|
|
174
|
+
const result = await runLinter(cmd, linter.timeout_ms ?? 10000, stdin);
|
|
175
|
+
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
if (result.reason === 'spawn-error' && (result.error === 'ENOENT' || /not found/i.test(result.stderr || ''))) {
|
|
178
|
+
// Linter not installed.
|
|
179
|
+
if (config.fallback === 'deny') {
|
|
180
|
+
return {
|
|
181
|
+
decision: DECISIONS.DENY,
|
|
182
|
+
reason: `auto-lint: linter for .${lang} (${cmd[0]}) is not installed on this machine and fallback is "deny".`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (config.log) {
|
|
186
|
+
process.stderr.write(`[auto-lint] ${lang} linter (${cmd[0]}) not found, allowing (fallback=allow)\n`);
|
|
187
|
+
}
|
|
188
|
+
return { decision: DECISIONS.ALLOW, reason: null, skipped: 'linter-missing' };
|
|
189
|
+
}
|
|
190
|
+
if (result.reason === 'timeout') {
|
|
191
|
+
return {
|
|
192
|
+
decision: DECISIONS.ASK,
|
|
193
|
+
reason: `auto-lint: ${lang} linter timed out after ${linter.timeout_ms}ms. Allow the change without linting?`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
decision: DECISIONS.ASK,
|
|
198
|
+
reason: `auto-lint: ${lang} linter failed to run: ${result.error || 'unknown'}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Linter ran. Decide based on its output.
|
|
203
|
+
const issues = (result.stdout || '').trim();
|
|
204
|
+
if (result.code === 0 && !issues) {
|
|
205
|
+
if (config.log) {
|
|
206
|
+
process.stderr.write(`[auto-lint] ${lang} ${target} -> clean\n`);
|
|
207
|
+
}
|
|
208
|
+
return { decision: DECISIONS.ALLOW, reason: null };
|
|
209
|
+
}
|
|
210
|
+
// Non-zero exit OR non-empty stdout = linter found something.
|
|
211
|
+
const reason =
|
|
212
|
+
`auto-lint: ${lang} linter reported issues in ${target}\n` +
|
|
213
|
+
(issues ? `--- stdout ---\n${issues}\n` : '') +
|
|
214
|
+
(result.stderr ? `--- stderr ---\n${result.stderr}\n` : '') +
|
|
215
|
+
`\nFix the issues (or run the linter manually) and re-apply the change.`;
|
|
216
|
+
if (config.log) {
|
|
217
|
+
process.stderr.write(`[auto-lint] ${lang} ${target} -> issues found\n`);
|
|
218
|
+
}
|
|
219
|
+
return { decision: DECISIONS.DENY, reason };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- CLI entry point ---------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
function readStdin() {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
let data = '';
|
|
227
|
+
process.stdin.setEncoding('utf8');
|
|
228
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
229
|
+
process.stdin.on('end', () => resolve(data));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const raw = await readStdin();
|
|
235
|
+
const parsed = parseHookInput(raw);
|
|
236
|
+
if (!parsed.ok) {
|
|
237
|
+
emitError(`auto-lint: ${parsed.error}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const result = await evaluate(parsed);
|
|
241
|
+
emitDecision(result.decision, result.reason);
|
|
242
|
+
if (result.decision === DECISIONS.DENY) {
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const isMain =
|
|
248
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
249
|
+
process.argv[1]?.endsWith('auto-lint.js');
|
|
250
|
+
if (isMain) {
|
|
251
|
+
main().catch((err) => emitError(err.stack || err.message));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default { evaluate, langFor, runLinter };
|