@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 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
- ## What this gate does not see (Claude Code)
67
+ ## Shell writes (Claude Code)
68
68
 
69
- The Claude Code matcher is `Write|Edit|MultiEdit`. A contract file written by a **shell
70
- command** (`cat > openapi.yaml`, `tee`, `python -c "open(...)"`, …) does not go through
71
- those tools, so this hook never runs. Parsing Bash to guess destination paths is not a
72
- gate — it would miss more than it caught. Treat a shell-written schema as unchecked.
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 artifact written via Bash or PowerShell was seen at all
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
 
@@ -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
- * `gate.js` is untouched. This file only remaps envelopes.
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", content: "new_string" },
21
- MultiEdit: { path: "file_path", content: "content" },
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 ?? {},
@@ -2,7 +2,7 @@
2
2
  "hooks": {
3
3
  "PreToolUse": [
4
4
  {
5
- "matcher": "Write|Edit|MultiEdit",
5
+ "matcher": "Write|Edit|MultiEdit|Bash",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
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: target.content },
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.0",
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": ["./index.js"],
37
+ "extensions": [
38
+ "./index.js"
39
+ ],
38
40
  "compat": {
39
41
  "pluginApi": ">=2026.6.35"
40
42
  },