@coderifts/agent-hooks 0.2.0 → 0.2.2
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +23 -6
- package/claude-code/hook.mjs +73 -4
- package/claude-code/settings.snippet.json +1 -1
- package/gate.js +17 -3
- package/hooks/hooks.json +2 -2
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 — 2026-10-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Every refusal names two more limits.** `DOES_NOT_PROVE` (gate.js), carried on every refusal and approval request, and inherited by the shell refusal, now also says:
|
|
8
|
+
- that a refusal by a hook installed outside managed settings is not final: since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked;
|
|
9
|
+
- that a timed-out hook does not block the call: the fail-closed path covers an error inside the hook, not Claude Code's hook timeout. This holds for this hook only when `CODERIFTS_TIMEOUT_MS` is at least the hook timeout; the default 5000 ms aborts inside the 8 s timeout the snippet sets.
|
|
10
|
+
|
|
11
|
+
## Unreleased — 2026-09-27
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Edit sent a fragment as the whole file.** `new_string` went to CodeRifts as the after body, so a
|
|
16
|
+
one-line Edit read as "everything else was removed". The after body is now the file on disk with
|
|
17
|
+
the edit applied (`replace_all` honoured); MultiEdit applies `edits` in order (it used to read a
|
|
18
|
+
`content` key that MultiEdit does not have). An edit that does not apply → exit 2, nothing sent.
|
|
19
|
+
The 0.2.0 test that pinned the fragment is rewritten; its `old_string` was not in the fixture.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **The Bash hole is narrowed, not closed.** Matcher `Write|Edit|MultiEdit|Bash`. A Bash command that
|
|
24
|
+
names a recognised contract file with a write shape is **denied** with a pointer to Write/Edit;
|
|
25
|
+
reads pass; nothing is sent to CodeRifts. Deny, not ask (#39344: a hook ask was reported to
|
|
26
|
+
override a settings deny). A write that does not name the file is still not seen — the refusal
|
|
27
|
+
text says so. Same bypass class as anthropics/claude-code #31292.
|
|
28
|
+
|
|
3
29
|
## 0.2.0 — 2026-09-13
|
|
4
30
|
|
|
5
31
|
**(a) New host.** Claude Code PreToolUse adapter. Same `gate.js`; new stdin envelope.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodeRifts
|
|
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
CHANGED
|
@@ -46,6 +46,8 @@ Does not prove:
|
|
|
46
46
|
- that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write
|
|
47
47
|
- that the other tool calls in this run were checked — each call is judged alone
|
|
48
48
|
- that a contract artifact this gate does not recognise was seen at all
|
|
49
|
+
- that the call was refused when this hook is installed outside managed settings — since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked; only a hook in managed settings is final
|
|
50
|
+
- that a call was refused when the hook ran out of time — the fail-closed path covers an error inside the hook, not Claude Code's hook timeout: a timed-out PreToolUse command hook does not block the call (true for this hook only when CODERIFTS_TIMEOUT_MS is at least the hook timeout)
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
## Fail-closed
|
|
@@ -64,15 +66,30 @@ The hook entry sets `"timeout": 8` (**seconds** — Claude Code's unit, not mill
|
|
|
64
66
|
gate's 5000 ms abort can finish first. If `node` itself hangs past 8 s, Claude Code still
|
|
65
67
|
lets the write through. That host behaviour cannot be fixed in this package.
|
|
66
68
|
|
|
67
|
-
##
|
|
69
|
+
## Shell writes (Claude Code)
|
|
68
70
|
|
|
69
|
-
The Claude Code matcher is `Write|Edit|MultiEdit`. A
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
The Claude Code matcher is `Write|Edit|MultiEdit|Bash`. A shell command cannot be gated — the
|
|
72
|
+
hook never sees the bytes it would leave — so the hook does something narrower: a Bash command
|
|
73
|
+
that **names a recognised contract file and has a write shape** (a redirect, `tee`, `sed -i`,
|
|
74
|
+
`perl -i`, `cp`/`mv`/`rm`, `git checkout|restore|apply`, `open(…, "w")`, `curl -o`, …) is
|
|
75
|
+
**denied** with a pointer to the Write or Edit tool, where the gate does see the change.
|
|
76
|
+
Reads (`cat`, `git diff`, `grep`, `oasdiff`) pass. Nothing is sent to CodeRifts for a Bash call.
|
|
77
|
+
|
|
78
|
+
It is **deny, not ask**: a hook `ask` was reported to override a settings deny rule
|
|
79
|
+
(anthropics/claude-code #39344), and an escalation that can downgrade someone else's deny is
|
|
80
|
+
not one.
|
|
81
|
+
|
|
82
|
+
It is not a parser. A write that does not name the file (`python script.py`, a variable, a
|
|
83
|
+
glob) is not recognised, and every shell refusal says so:
|
|
73
84
|
|
|
74
85
|
Does not prove:
|
|
75
|
-
- that a contract
|
|
86
|
+
- that a contract file written by a shell command this hook did not recognise was seen at all
|
|
87
|
+
|
|
88
|
+
## Edit and MultiEdit
|
|
89
|
+
|
|
90
|
+
`new_string` is a fragment, not the file. The gate reads the file on disk and sends it with the
|
|
91
|
+
edit applied (MultiEdit: every edit, in order). An edit whose `old_string` is not in the file is
|
|
92
|
+
not guessed: exit 2.
|
|
76
93
|
|
|
77
94
|
## Configuration
|
|
78
95
|
|
package/claude-code/hook.mjs
CHANGED
|
@@ -8,19 +8,83 @@
|
|
|
8
8
|
* block → JSON deny (reason includes Does not prove)
|
|
9
9
|
* transport/parse/throw → exit 2 + stderr (the host is fail-open otherwise)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Two Claude-specific inputs are resolved here, before the gate (2026-09-27):
|
|
12
|
+
* - Edit/MultiEdit carry an edit, not a body; `applyEdits` builds the after file from the one on disk.
|
|
13
|
+
* - Bash is not gated (the after bytes are unknown), but a command that names a recognised contract
|
|
14
|
+
* file with a write shape is denied with a pointer to Write/Edit, where the gate does see it.
|
|
12
15
|
*/
|
|
13
16
|
import { resolve } from "node:path";
|
|
14
17
|
import { pathToFileURL } from "node:url";
|
|
15
|
-
import { createGate } from "../gate.js";
|
|
18
|
+
import { classifyPath, createGate, DOES_NOT_PROVE } from "../gate.js";
|
|
19
|
+
|
|
20
|
+
/** One Claude Code edit applied to `text`; null when old_string is not there to replace. */
|
|
21
|
+
function applyOne(text, { old_string: from, new_string: to, replace_all: all } = {}) {
|
|
22
|
+
if (typeof from !== "string" || typeof to !== "string") return null;
|
|
23
|
+
if (from === "") return text === "" ? to : null; // Claude Code's "create via Edit" shape
|
|
24
|
+
if (!text.includes(from)) return null;
|
|
25
|
+
return all ? text.split(from).join(to) : text.replace(from, () => to);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The file after an Edit (`params` is the edit) or a MultiEdit (`params.edits`, in order). */
|
|
29
|
+
export function applyEdits(params, before) {
|
|
30
|
+
const edits = Array.isArray(params.edits) ? params.edits : [params];
|
|
31
|
+
let text = before;
|
|
32
|
+
for (const e of edits) {
|
|
33
|
+
text = applyOne(text, e);
|
|
34
|
+
if (text === null) return null;
|
|
35
|
+
}
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
/** Claude Code Write/Edit/MultiEdit → the path/content keys `resolveTarget` reads. */
|
|
18
40
|
export const CLAUDE_TOOL_SHAPES = Object.freeze({
|
|
19
41
|
Write: { path: "file_path", content: "content" },
|
|
20
|
-
Edit: { path: "file_path",
|
|
21
|
-
MultiEdit: { path: "file_path",
|
|
42
|
+
Edit: { path: "file_path", apply: applyEdits },
|
|
43
|
+
MultiEdit: { path: "file_path", apply: applyEdits },
|
|
22
44
|
});
|
|
23
45
|
|
|
46
|
+
/*
|
|
47
|
+
* A write shape in a shell command. Not a parser and not a gate: it only decides whether a command
|
|
48
|
+
* that already NAMES a recognised contract file may be changing it. A miss is named in the refusal
|
|
49
|
+
* text (and the README); a false hit costs one redirect to the Write/Edit tool.
|
|
50
|
+
*/
|
|
51
|
+
const SHELL_WRITE = [
|
|
52
|
+
/>/, // any redirect, incl. >> and heredoc targets
|
|
53
|
+
/\btee\b/,
|
|
54
|
+
/\b(sed|perl|ruby)\b[^|;&]*\s-[a-zA-Z]*i/,
|
|
55
|
+
/\b(cp|mv|rm|truncate|install|ln|rsync|dd|patch|unzip|tar)\b/,
|
|
56
|
+
/\bgit\s+(checkout|restore|apply|am|reset|stash|mv|rm)\b/,
|
|
57
|
+
/\bopen\s*\([^)]*,\s*['"][wax+]/,
|
|
58
|
+
/\b(writeFile(Sync)?|write_text|write_bytes)\b/,
|
|
59
|
+
/\b(curl|wget)\b[^|;&]*\s-(o|O)\b/,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Tokens a shell would split on, then the ones this gate recognises as a contract path. */
|
|
63
|
+
function contractPathsIn(command) {
|
|
64
|
+
const tokens = command.split(/[\s;|&<>()'"`=,]+/).filter(Boolean);
|
|
65
|
+
return [...new Set(tokens.filter((t) => classifyPath(t)))];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SHELL_DOES_NOT_PROVE = [
|
|
69
|
+
"that a contract file written by a shell command this hook did not recognise was seen at all",
|
|
70
|
+
...DOES_NOT_PROVE.slice(1),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function shellContractWrite(command) {
|
|
74
|
+
if (typeof command !== "string") return null;
|
|
75
|
+
// A redirect into a file descriptor (2>&1, >&2) is not a write to a path.
|
|
76
|
+
const probe = command.replace(/\d?>&\d/g, " ");
|
|
77
|
+
const paths = contractPathsIn(probe);
|
|
78
|
+
if (!paths.length || !SHELL_WRITE.some((re) => re.test(probe))) return null;
|
|
79
|
+
return [
|
|
80
|
+
`CodeRifts cannot see a contract change made by a shell command (${paths.join(", ")}).`,
|
|
81
|
+
`Use the Write or Edit tool for contract files, so the change is checked before it lands.`,
|
|
82
|
+
`Proves: only that this command names a recognised contract file with a write shape.`,
|
|
83
|
+
`Does not prove:`,
|
|
84
|
+
...SHELL_DOES_NOT_PROVE.map((l) => ` - ${l}`),
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
24
88
|
const ASK_TITLES = new Set([
|
|
25
89
|
"CodeRifts asks for approval",
|
|
26
90
|
"CodeRifts returned analysis, not authorization",
|
|
@@ -86,6 +150,11 @@ export async function runClaudeHook(stdinText, { env = process.env, deps } = {})
|
|
|
86
150
|
return failClosed("CodeRifts Claude hook: stdin JSON was not an object.");
|
|
87
151
|
}
|
|
88
152
|
|
|
153
|
+
if (payload.tool_name === "Bash") {
|
|
154
|
+
const refusal = shellContractWrite(payload.tool_input?.command);
|
|
155
|
+
return refusal ? jsonDecision("deny", refusal) : emptyPass();
|
|
156
|
+
}
|
|
157
|
+
|
|
89
158
|
const event = {
|
|
90
159
|
toolName: payload.tool_name,
|
|
91
160
|
params: payload.tool_input ?? {},
|
package/gate.js
CHANGED
|
@@ -47,6 +47,8 @@ export const DOES_NOT_PROVE = Object.freeze([
|
|
|
47
47
|
"that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write",
|
|
48
48
|
"that the other tool calls in this run were checked — each call is judged alone",
|
|
49
49
|
"that a contract artifact this gate does not recognise was seen at all",
|
|
50
|
+
"that the call was refused when this hook is installed outside managed settings — since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked; only a hook in managed settings is final",
|
|
51
|
+
"that a call was refused when the hook ran out of time — the fail-closed path covers an error inside the hook, not Claude Code's hook timeout: a timed-out PreToolUse command hook does not block the call (true for this hook only when CODERIFTS_TIMEOUT_MS is at least the hook timeout)",
|
|
50
52
|
]);
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -216,7 +218,7 @@ export function resolveTarget(event, shapes) {
|
|
|
216
218
|
const candidates = [fromParams, ...(event.derivedPaths ?? [])].filter((p) => typeof p === "string" && p);
|
|
217
219
|
for (const p of candidates) {
|
|
218
220
|
const type = classifyPath(p);
|
|
219
|
-
if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined };
|
|
221
|
+
if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined, apply: shape?.apply };
|
|
220
222
|
}
|
|
221
223
|
return null;
|
|
222
224
|
}
|
|
@@ -236,7 +238,7 @@ export function createGate(config = {}, deps = {}) {
|
|
|
236
238
|
// Not a contract artifact this gate recognises. Staying out of the way is not a fail-open: the
|
|
237
239
|
// gate never claimed this call, and DOES_NOT_PROVE says as much on every refusal it does make.
|
|
238
240
|
if (!target) return undefined;
|
|
239
|
-
if (typeof target.content !== "string") {
|
|
241
|
+
if (!target.apply && typeof target.content !== "string") {
|
|
240
242
|
return ask(
|
|
241
243
|
"CodeRifts gate could not read the proposed change",
|
|
242
244
|
`${target.path} is a contract artifact, but this gate could not find the new content in the ` +
|
|
@@ -251,12 +253,24 @@ export function createGate(config = {}, deps = {}) {
|
|
|
251
253
|
before = ""; // A new file. An empty "before" is a real change set, not a missing one.
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
// A tool whose params carry an edit, not a body (Claude Code Edit/MultiEdit): the after body is
|
|
257
|
+
// the file with the edit applied. An edit that does not apply is put to a human — sending a
|
|
258
|
+
// fragment as the whole file would read as "everything else was removed".
|
|
259
|
+
const after = target.apply ? target.apply(event.params ?? {}, before) : target.content;
|
|
260
|
+
if (typeof after !== "string") {
|
|
261
|
+
return ask(
|
|
262
|
+
"CodeRifts gate could not read the proposed change",
|
|
263
|
+
`${target.path} is a contract artifact, but the edit in "${event.toolName}" does not apply to ` +
|
|
264
|
+
`the file as it is on disk. It will not pass a change it has not seen.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
254
268
|
const outcome = await call({
|
|
255
269
|
endpoint,
|
|
256
270
|
apiKey,
|
|
257
271
|
timeoutMs,
|
|
258
272
|
operation,
|
|
259
|
-
artifact: { id: target.path, type: target.type, before, after
|
|
273
|
+
artifact: { id: target.path, type: target.type, before, after },
|
|
260
274
|
});
|
|
261
275
|
return decide(outcome, { operation, path: target.path });
|
|
262
276
|
};
|
package/hooks/hooks.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Ask CodeRifts before Write/Edit of a contract artifact. Host timeout is 8s (seconds). Gate budget is 5000ms. If this process hangs past the host timeout, Claude Code fail-opens — named in the README.",
|
|
2
|
+
"description": "Ask CodeRifts before Write/Edit of a contract artifact; deny a Bash command that names one with a write shape. Host timeout is 8s (seconds). Gate budget is 5000ms. If this process hangs past the host timeout, Claude Code fail-opens — named in the README.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
6
|
-
"matcher": "Write|Edit|MultiEdit",
|
|
6
|
+
"matcher": "Write|Edit|MultiEdit|Bash",
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderifts/agent-hooks",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Ask CodeRifts before an agent writes a contract artifact (OpenClaw before_tool_call and Claude Code PreToolUse).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/coderifts/agent-hooks#readme",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"openclaw": {
|
|
37
|
-
"extensions": [
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./index.js"
|
|
39
|
+
],
|
|
38
40
|
"compat": {
|
|
39
41
|
"pluginApi": ">=2026.6.35"
|
|
40
42
|
},
|