@coderifts/agent-hooks 0.2.0 → 0.2.1
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 +18 -0
- package/LICENSE +21 -0
- package/README.md +21 -6
- package/claude-code/hook.mjs +73 -4
- package/claude-code/settings.snippet.json +1 -1
- package/gate.js +15 -3
- package/hooks/hooks.json +2 -2
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased — 2026-09-27
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Edit sent a fragment as the whole file.** `new_string` went to CodeRifts as the after body, so a
|
|
8
|
+
one-line Edit read as "everything else was removed". The after body is now the file on disk with
|
|
9
|
+
the edit applied (`replace_all` honoured); MultiEdit applies `edits` in order (it used to read a
|
|
10
|
+
`content` key that MultiEdit does not have). An edit that does not apply → exit 2, nothing sent.
|
|
11
|
+
The 0.2.0 test that pinned the fragment is rewritten; its `old_string` was not in the fixture.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **The Bash hole is narrowed, not closed.** Matcher `Write|Edit|MultiEdit|Bash`. A Bash command that
|
|
16
|
+
names a recognised contract file with a write shape is **denied** with a pointer to Write/Edit;
|
|
17
|
+
reads pass; nothing is sent to CodeRifts. Deny, not ask (#39344: a hook ask was reported to
|
|
18
|
+
override a settings deny). A write that does not name the file is still not seen — the refusal
|
|
19
|
+
text says so. Same bypass class as anthropics/claude-code #31292.
|
|
20
|
+
|
|
3
21
|
## 0.2.0 — 2026-09-13
|
|
4
22
|
|
|
5
23
|
**(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
|
@@ -64,15 +64,30 @@ The hook entry sets `"timeout": 8` (**seconds** — Claude Code's unit, not mill
|
|
|
64
64
|
gate's 5000 ms abort can finish first. If `node` itself hangs past 8 s, Claude Code still
|
|
65
65
|
lets the write through. That host behaviour cannot be fixed in this package.
|
|
66
66
|
|
|
67
|
-
##
|
|
67
|
+
## Shell writes (Claude Code)
|
|
68
68
|
|
|
69
|
-
The Claude Code matcher is `Write|Edit|MultiEdit`. A
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
The Claude Code matcher is `Write|Edit|MultiEdit|Bash`. A shell command cannot be gated — the
|
|
70
|
+
hook never sees the bytes it would leave — so the hook does something narrower: a Bash command
|
|
71
|
+
that **names a recognised contract file and has a write shape** (a redirect, `tee`, `sed -i`,
|
|
72
|
+
`perl -i`, `cp`/`mv`/`rm`, `git checkout|restore|apply`, `open(…, "w")`, `curl -o`, …) is
|
|
73
|
+
**denied** with a pointer to the Write or Edit tool, where the gate does see the change.
|
|
74
|
+
Reads (`cat`, `git diff`, `grep`, `oasdiff`) pass. Nothing is sent to CodeRifts for a Bash call.
|
|
75
|
+
|
|
76
|
+
It is **deny, not ask**: a hook `ask` was reported to override a settings deny rule
|
|
77
|
+
(anthropics/claude-code #39344), and an escalation that can downgrade someone else's deny is
|
|
78
|
+
not one.
|
|
79
|
+
|
|
80
|
+
It is not a parser. A write that does not name the file (`python script.py`, a variable, a
|
|
81
|
+
glob) is not recognised, and every shell refusal says so:
|
|
73
82
|
|
|
74
83
|
Does not prove:
|
|
75
|
-
- that a contract
|
|
84
|
+
- that a contract file written by a shell command this hook did not recognise was seen at all
|
|
85
|
+
|
|
86
|
+
## Edit and MultiEdit
|
|
87
|
+
|
|
88
|
+
`new_string` is a fragment, not the file. The gate reads the file on disk and sends it with the
|
|
89
|
+
edit applied (MultiEdit: every edit, in order). An edit whose `old_string` is not in the file is
|
|
90
|
+
not guessed: exit 2.
|
|
76
91
|
|
|
77
92
|
## Configuration
|
|
78
93
|
|
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
|
@@ -216,7 +216,7 @@ export function resolveTarget(event, shapes) {
|
|
|
216
216
|
const candidates = [fromParams, ...(event.derivedPaths ?? [])].filter((p) => typeof p === "string" && p);
|
|
217
217
|
for (const p of candidates) {
|
|
218
218
|
const type = classifyPath(p);
|
|
219
|
-
if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined };
|
|
219
|
+
if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined, apply: shape?.apply };
|
|
220
220
|
}
|
|
221
221
|
return null;
|
|
222
222
|
}
|
|
@@ -236,7 +236,7 @@ export function createGate(config = {}, deps = {}) {
|
|
|
236
236
|
// Not a contract artifact this gate recognises. Staying out of the way is not a fail-open: the
|
|
237
237
|
// gate never claimed this call, and DOES_NOT_PROVE says as much on every refusal it does make.
|
|
238
238
|
if (!target) return undefined;
|
|
239
|
-
if (typeof target.content !== "string") {
|
|
239
|
+
if (!target.apply && typeof target.content !== "string") {
|
|
240
240
|
return ask(
|
|
241
241
|
"CodeRifts gate could not read the proposed change",
|
|
242
242
|
`${target.path} is a contract artifact, but this gate could not find the new content in the ` +
|
|
@@ -251,12 +251,24 @@ export function createGate(config = {}, deps = {}) {
|
|
|
251
251
|
before = ""; // A new file. An empty "before" is a real change set, not a missing one.
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// A tool whose params carry an edit, not a body (Claude Code Edit/MultiEdit): the after body is
|
|
255
|
+
// the file with the edit applied. An edit that does not apply is put to a human — sending a
|
|
256
|
+
// fragment as the whole file would read as "everything else was removed".
|
|
257
|
+
const after = target.apply ? target.apply(event.params ?? {}, before) : target.content;
|
|
258
|
+
if (typeof after !== "string") {
|
|
259
|
+
return ask(
|
|
260
|
+
"CodeRifts gate could not read the proposed change",
|
|
261
|
+
`${target.path} is a contract artifact, but the edit in "${event.toolName}" does not apply to ` +
|
|
262
|
+
`the file as it is on disk. It will not pass a change it has not seen.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
254
266
|
const outcome = await call({
|
|
255
267
|
endpoint,
|
|
256
268
|
apiKey,
|
|
257
269
|
timeoutMs,
|
|
258
270
|
operation,
|
|
259
|
-
artifact: { id: target.path, type: target.type, before, after
|
|
271
|
+
artifact: { id: target.path, type: target.type, before, after },
|
|
260
272
|
});
|
|
261
273
|
return decide(outcome, { operation, path: target.path });
|
|
262
274
|
};
|
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.1",
|
|
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
|
},
|