@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 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
- ## What this gate does not see (Claude Code)
69
+ ## Shell writes (Claude Code)
68
70
 
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.
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 artifact written via Bash or PowerShell was seen at all
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
 
@@ -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
@@ -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: target.content },
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.0",
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": ["./index.js"],
37
+ "extensions": [
38
+ "./index.js"
39
+ ],
38
40
  "compat": {
39
41
  "pluginApi": ">=2026.6.35"
40
42
  },