@coderifts/agent-hooks 0.2.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.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "agent-hooks",
3
+ "version": "0.2.0",
4
+ "description": "Ask CodeRifts before Claude Code writes a contract artifact (OpenAPI, AsyncAPI, GraphQL, protobuf, MCP manifest).",
5
+ "author": {
6
+ "name": "CodeRifts",
7
+ "email": "peter@coderifts.com"
8
+ },
9
+ "homepage": "https://coderifts.com",
10
+ "repository": "https://github.com/coderifts/agent-hooks",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "coderifts",
14
+ "pretooluse",
15
+ "contract-change",
16
+ "openapi",
17
+ "hooks"
18
+ ]
19
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-09-13
4
+
5
+ **(a) New host.** Claude Code PreToolUse adapter. Same `gate.js`; new stdin envelope.
6
+
7
+ **(b) Rename.** npm package is `@coderifts/agent-hooks` (was `@coderifts/openclaw-plugin`).
8
+ The package is no longer one host's plugin: OpenClaw and Claude Code share `gate.js`,
9
+ and a Docker `before:exec` adapter is measured but not shipped. The Claude marketplace
10
+ plugin id is `agent-hooks`, not `contract-gate` — that name is the GitHub Action
11
+ (`coderifts/contract-gate`). `@coderifts/agent-guard` remains the wrapWithGuard library.
12
+
13
+ ### Added
14
+
15
+ - `claude-code/hook.mjs`: maps Write/Edit/MultiEdit stdin JSON onto `createGate`, then:
16
+ - CONTINUE → exit 0, empty stdout (never `permissionDecision: allow`)
17
+ - REQUEST_APPROVAL / keyless analyze → JSON `ask`
18
+ - STOP → JSON `deny` (reason still carries Does not prove)
19
+ - unreachable / unreadable / throw / bad stdin → **exit 2 + stderr** because Claude Code command hooks are fail-open on timeout, exit 1, and missing scripts
20
+ - Plugin wiring: `hooks/hooks.json` (`timeout: 8` **seconds**), `.claude-plugin/plugin.json`, copyable `claude-code/settings.snippet.json`, `claude-code/marketplace-entry.json` for the existing `coderifts` marketplace
21
+ - Adapter tests (`test/claude-hook.test.js`) and pack coverage for the adapter file
22
+
23
+ ### Limits (named, not silent)
24
+
25
+ - **Host timeout fail-open.** Hook config `timeout` is seconds (default 600). We set 8, above the gate's 5000 ms, so a slow CodeRifts answer becomes exit 2 from us. If `node` hangs past 8 s, Claude Code **still lets the tool run**. This cannot be fixed in the hook.
26
+ - **Bash hole.** Matcher is `Write|Edit|MultiEdit`. A shell command that writes `openapi.yaml` (`cat > …`, `tee`, …) is not seen. Parsing those commands is not a gate; the README says so.
27
+
28
+ ## 0.1.1 — 2026-09-13
29
+
30
+ **0.1.0 was broken.** `npm pack` / `openclaw plugins install` shipped a tarball
31
+ without `gate.js`. `index.js` imports `./gate.js`, so the installed plugin failed
32
+ to load (`Cannot find module './gate.js'`). The 14 in-repo tests imported from
33
+ the checkout, not from the packed tarball, and did not catch this.
34
+
35
+ ### Fixed
36
+
37
+ - Include `gate.js` in `package.json` `"files"`.
38
+ - Add a pack-then-install test (`test/pack.test.js`) that fails if any relative
39
+ import of the packed `index.js` is missing from the tarball.
40
+ - Declare `openclaw` as a peerDependency (`>=2026.6.35`, optional so npm does
41
+ not fetch the host as a nested install). Without the peer, `npm install` of
42
+ 0.1.0 was silent about a missing host.
43
+ - Add `openclaw.build.openclawVersion` (`2026.6.35`) — ClawHub `package publish`
44
+ rejects external code plugins that omit it.
45
+
46
+ ## 0.1.0 — 2026-09-13
47
+
48
+ Initial release. **Do not use.** The published tarball omitted `gate.js`.
49
+ Use 0.1.1.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # CodeRifts agent hooks (`@coderifts/agent-hooks`)
2
+
3
+ Asks CodeRifts before an agent writes a contract artifact — an OpenAPI, AsyncAPI, GraphQL,
4
+ protobuf or MCP manifest file. **Hosts today:** OpenClaw `before_tool_call` and Claude Code
5
+ `PreToolUse`. **Not built:** the Docker MCP Gateway `before:exec` interceptor — that path
6
+ was measured (empty stdout = pass, CallToolResult JSON = block, HTTP 4xx empty = fail-open)
7
+ and has no adapter in this package yet.
8
+
9
+ This is the host-adapter package. It is not `@coderifts/agent-guard` (the wrapWithGuard
10
+ library) and not `coderifts/contract-gate` (the GitHub Action). The npm name used to be
11
+ `@coderifts/openclaw-plugin`; that name is wrong now that more than one host is wired.
12
+
13
+ The same `gate.js` drives every host. The gate makes no policy decisions of its own. It
14
+ recognises the file, sends the before/after to CodeRifts, and maps the answer back.
15
+ Everything it cannot map, it puts to a human.
16
+
17
+ ## What it does
18
+
19
+ | CodeRifts `execution_action` | OpenClaw | Claude Code PreToolUse |
20
+ | --- | --- | --- |
21
+ | `CONTINUE`, `CONTINUE_WITH_MONITORING` | pass through | exit 0, empty stdout (never `allow`) |
22
+ | `REQUEST_APPROVAL` | `requireApproval` | JSON `permissionDecision: "ask"` |
23
+ | `STOP` | `block`, with the reason and its limits | JSON `permissionDecision: "deny"` |
24
+ | anything else, or no answer | `requireApproval` | **exit 2 + stderr** (host is fail-open) |
25
+
26
+ ## What it does not do
27
+
28
+ - **It does not gate what it does not recognise.** A tool call writing `README.md` passes untouched.
29
+ The recognised set is a short, explicit list in `gate.js`; a gate that guesses at every file either
30
+ blocks documentation or waves through a schema.
31
+ - **It does not turn a risk number into a verdict.** Without an API key CodeRifts answers in
32
+ `analyze` mode, which returns `authorization_effect: "NONE"` and `may_execute: false` — risk
33
+ information and no decision. The gate reports that and asks a human. It never reads a high score
34
+ as a block or a low one as a pass.
35
+ - **It does not lock the file.** The bytes checked are the bytes in the tool params at the moment of
36
+ the call. Nothing here holds them between the answer and the write.
37
+
38
+ Every refusal says so in its own text, so an agent reading the transcript can see how far the answer
39
+ reaches:
40
+
41
+ ```
42
+ CodeRifts refused this contract change.
43
+ Reason: endpoint_removed — ENDPOINT_REMOVAL
44
+ Proves: the change set as this call would leave it was refused by CodeRifts under operation "tool_call".
45
+ Does not prove:
46
+ - that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write
47
+ - that the other tool calls in this run were checked — each call is judged alone
48
+ - that a contract artifact this gate does not recognise was seen at all
49
+ ```
50
+
51
+ ## Fail-closed
52
+
53
+ If CodeRifts is unreachable, times out, or answers something the gate cannot read, the result is
54
+ not a pass. Not knowing is not permission.
55
+
56
+ The gate's own budget defaults to 5000 ms.
57
+
58
+ On **OpenClaw**, that is well under the host's 15000 ms `before_tool_call` budget, so a slow
59
+ answer becomes a readable approval request, not an opaque host denial. The host is fail-closed.
60
+
61
+ On **Claude Code**, the host is **fail-open**: a timed-out command hook, exit 1, invalid JSON,
62
+ or a missing script lets the tool run. The adapter therefore maps those failures to **exit 2**.
63
+ The hook entry sets `"timeout": 8` (**seconds** — Claude Code's unit, not milliseconds) so the
64
+ gate's 5000 ms abort can finish first. If `node` itself hangs past 8 s, Claude Code still
65
+ lets the write through. That host behaviour cannot be fixed in this package.
66
+
67
+ ## What this gate does not see (Claude Code)
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.
73
+
74
+ Does not prove:
75
+ - that a contract artifact written via Bash or PowerShell was seen at all
76
+
77
+ ## Configuration
78
+
79
+ ```json
80
+ {
81
+ "plugins": {
82
+ "entries": {
83
+ "coderifts-contract-gate": {
84
+ "config": {
85
+ "apiKey": "...",
86
+ "endpoint": "https://app.coderifts.com/mcp",
87
+ "timeoutMs": 5000,
88
+ "operation": "tool_call"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ `apiKey` is optional in the schema and load-bearing in practice: **without it there is no decision.**
97
+ `preflight_mode=authorize` requires a verified issuer, so an unkeyed gate runs in `analyze` mode and
98
+ every gated call ends at a human. That is safe and it is noisy; the key is what makes it useful.
99
+
100
+ There is no setting that makes an unanswered call pass. That absence is the design.
101
+
102
+ ## Hook placement
103
+
104
+ Registered on `before_tool_call` at `priority: 100`. OpenClaw runs handlers in descending priority
105
+ and keeps only the **first** `requireApproval`, so a gate that runs late can have its question
106
+ dropped by an earlier plugin. A `block` is sticky and survives regardless of order.
107
+
108
+ ## Install — OpenClaw
109
+
110
+ ```bash
111
+ openclaw plugins install @coderifts/agent-hooks
112
+ ```
113
+
114
+ (OpenClaw still records the plugin under the runtime id `coderifts-contract-gate` — that is
115
+ the OpenClaw config key, not the GitHub Action.)
116
+
117
+ ## Install — Claude Code
118
+
119
+ Copy `claude-code/settings.snippet.json` into `.claude/settings.json` (or merge the
120
+ `hooks` key), after `npm install @coderifts/agent-hooks`. Optional env:
121
+ `CODERIFTS_API_KEY`, `CODERIFTS_ENDPOINT`, `CODERIFTS_TIMEOUT_MS` (milliseconds, gate
122
+ budget; default 5000).
123
+
124
+ Plugin shape (for the existing `coderifts` marketplace): `.claude-plugin/plugin.json` +
125
+ `hooks/hooks.json`. Add `claude-code/marketplace-entry.json` to
126
+ `coderifts/api-governance` `.claude-plugin/marketplace.json` `plugins` array — that
127
+ catalog today lists only `api-governance`. Then:
128
+
129
+ ```bash
130
+ claude plugin marketplace update coderifts
131
+ claude plugin install agent-hooks@coderifts
132
+ ```
133
+
134
+ Do not use `permissionDecision: allow` for CONTINUE. Empty exit 0 leaves the normal
135
+ permission prompt in place.
136
+
137
+ ## Test
138
+
139
+ ```bash
140
+ npm test
141
+ ```
142
+
143
+ `npm test` includes a pack-then-install case. In-repo imports cannot see a
144
+ `files` allowlist that dropped a relative module — that is how 0.1.0 shipped
145
+ without `gate.js`. The pack test also requires `claude-code/hook.mjs`. Do not
146
+ skip it.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Claude Code PreToolUse adapter. Translates the host's stdin JSON into the
3
+ * OpenClaw-shaped event `createGate` already understands, then maps the three
4
+ * gate results onto the Claude Code contract (measured 2026-09-13):
5
+ *
6
+ * undefined → exit 0, empty stdout (NOT permissionDecision allow)
7
+ * requireApproval → JSON ask (named CodeRifts decision)
8
+ * block → JSON deny (reason includes Does not prove)
9
+ * transport/parse/throw → exit 2 + stderr (the host is fail-open otherwise)
10
+ *
11
+ * `gate.js` is untouched. This file only remaps envelopes.
12
+ */
13
+ import { resolve } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { createGate } from "../gate.js";
16
+
17
+ /** Claude Code Write/Edit/MultiEdit → the path/content keys `resolveTarget` reads. */
18
+ export const CLAUDE_TOOL_SHAPES = Object.freeze({
19
+ Write: { path: "file_path", content: "content" },
20
+ Edit: { path: "file_path", content: "new_string" },
21
+ MultiEdit: { path: "file_path", content: "content" },
22
+ });
23
+
24
+ const ASK_TITLES = new Set([
25
+ "CodeRifts asks for approval",
26
+ "CodeRifts returned analysis, not authorization",
27
+ ]);
28
+
29
+ function emptyPass() {
30
+ return { exitCode: 0, stdout: "", stderr: "" };
31
+ }
32
+
33
+ function jsonDecision(permissionDecision, reason) {
34
+ return {
35
+ exitCode: 0,
36
+ stdout:
37
+ JSON.stringify({
38
+ hookSpecificOutput: {
39
+ hookEventName: "PreToolUse",
40
+ permissionDecision,
41
+ permissionDecisionReason: reason,
42
+ },
43
+ }) + "\n",
44
+ stderr: "",
45
+ };
46
+ }
47
+
48
+ function failClosed(message) {
49
+ return { exitCode: 2, stdout: "", stderr: String(message).trim() + "\n" };
50
+ }
51
+
52
+ export function configFromEnv(env = process.env) {
53
+ const timeoutRaw = env.CODERIFTS_TIMEOUT_MS;
54
+ const timeoutMs = timeoutRaw === undefined || timeoutRaw === "" ? undefined : Number(timeoutRaw);
55
+ return {
56
+ apiKey: env.CODERIFTS_API_KEY || undefined,
57
+ endpoint: env.CODERIFTS_ENDPOINT || undefined,
58
+ timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
59
+ operation: env.CODERIFTS_OPERATION || undefined,
60
+ toolShapes: CLAUDE_TOOL_SHAPES,
61
+ };
62
+ }
63
+
64
+ export function mapGateResult(result) {
65
+ if (result === undefined) return emptyPass();
66
+ if (result?.block) {
67
+ return jsonDecision("deny", result.blockReason ?? "CodeRifts refused this contract change.");
68
+ }
69
+ const approval = result?.requireApproval;
70
+ if (approval) {
71
+ const reason = approval.description ?? approval.title ?? "CodeRifts asks for a human.";
72
+ if (ASK_TITLES.has(approval.title)) return jsonDecision("ask", reason);
73
+ return failClosed(reason);
74
+ }
75
+ return failClosed("CodeRifts gate returned a shape this adapter does not map. Not knowing is not permission.");
76
+ }
77
+
78
+ export async function runClaudeHook(stdinText, { env = process.env, deps } = {}) {
79
+ let payload;
80
+ try {
81
+ payload = JSON.parse(stdinText);
82
+ } catch (err) {
83
+ return failClosed(`CodeRifts Claude hook: stdin was not JSON (${err?.message ?? err}).`);
84
+ }
85
+ if (!payload || typeof payload !== "object") {
86
+ return failClosed("CodeRifts Claude hook: stdin JSON was not an object.");
87
+ }
88
+
89
+ const event = {
90
+ toolName: payload.tool_name,
91
+ params: payload.tool_input ?? {},
92
+ };
93
+
94
+ try {
95
+ const gate = createGate(configFromEnv(env), deps);
96
+ const result = await gate(event);
97
+ return mapGateResult(result);
98
+ } catch (err) {
99
+ return failClosed(
100
+ `CodeRifts Claude hook failed before a decision (${String(err?.message ?? err)}). Not knowing is not permission.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ async function main() {
106
+ const chunks = [];
107
+ for await (const chunk of process.stdin) chunks.push(chunk);
108
+ const raw = Buffer.concat(chunks).toString("utf8");
109
+ const out = await runClaudeHook(raw);
110
+ if (out.stdout) process.stdout.write(out.stdout);
111
+ if (out.stderr) process.stderr.write(out.stderr);
112
+ process.exit(out.exitCode);
113
+ }
114
+
115
+ const invokedDirectly =
116
+ Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
117
+ if (invokedDirectly) {
118
+ main();
119
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "agent-hooks",
3
+ "source": {
4
+ "source": "github",
5
+ "repo": "coderifts/agent-hooks"
6
+ },
7
+ "description": "Host adapters for CodeRifts: Claude Code PreToolUse asks before Write/Edit of OpenAPI, AsyncAPI, GraphQL, protobuf, or MCP manifest files. Not the GitHub Action (coderifts/contract-gate) and not the guard library (@coderifts/agent-guard).",
8
+ "version": "0.2.0",
9
+ "author": {
10
+ "name": "CodeRifts",
11
+ "email": "peter@coderifts.com"
12
+ },
13
+ "homepage": "https://coderifts.com",
14
+ "repository": "https://github.com/coderifts/agent-hooks",
15
+ "license": "MIT",
16
+ "keywords": ["coderifts", "pretooluse", "openapi", "hooks"],
17
+ "category": "development"
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Write|Edit|MultiEdit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "timeout": 8,
10
+ "command": "node",
11
+ "args": [
12
+ "${CLAUDE_PROJECT_DIR}/node_modules/@coderifts/agent-hooks/claude-code/hook.mjs"
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
19
+ }
package/gate.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The gate itself, with no OpenClaw imports, so it can be exercised directly.
3
+ *
4
+ * `index.js` is the OpenClaw entry; everything decidable lives here.
5
+ */
6
+
7
+ import { readFile } from "node:fs/promises";
8
+
9
+ /**
10
+ * Path suffix -> the `type` the CodeRifts change-set surface expects.
11
+ *
12
+ * Deliberately a short, explicit list. A gate that tries to recognise every contract artifact ends
13
+ * up guessing, and a gate that guesses either blocks documentation or waves through a schema. What
14
+ * is not on this list is not gated — and the approval text says so, so nobody reads a CONTINUE as
15
+ * "the whole change was checked".
16
+ */
17
+ export const ARTIFACT_TYPES = Object.freeze([
18
+ [/(^|\/)openapi[^/]*\.(ya?ml|json)$/i, "openapi"],
19
+ [/(^|\/)swagger[^/]*\.(ya?ml|json)$/i, "openapi"],
20
+ [/(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i, "asyncapi"],
21
+ [/\.graphql$|\.gql$/i, "graphql"],
22
+ [/\.proto$/i, "protobuf"],
23
+ [/(^|\/)(mcp|tools)\.(wire\.v1\.)?json$/i, "mcp"],
24
+ ]);
25
+
26
+ /** Tools whose params carry a path and a new file body. */
27
+ export const DEFAULT_TOOL_SHAPES = Object.freeze({
28
+ write_file: { path: "path", content: "content" },
29
+ create_file: { path: "path", content: "content" },
30
+ edit_file: { path: "path", content: "content" },
31
+ str_replace_editor: { path: "path", content: "new_str" },
32
+ });
33
+
34
+ export function classifyPath(p) {
35
+ for (const [re, type] of ARTIFACT_TYPES) if (re.test(p)) return type;
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * What this gate proves, and what it does not. Carried on EVERY refusal.
41
+ *
42
+ * An agent that reads "DENIED" and nothing else learns only that something said no. These lines are
43
+ * the difference between a policy engine's verdict and a reviewable one: they tell the agent — and
44
+ * the human reading the transcript — exactly how far the answer reaches.
45
+ */
46
+ export const DOES_NOT_PROVE = Object.freeze([
47
+ "that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write",
48
+ "that the other tool calls in this run were checked — each call is judged alone",
49
+ "that a contract artifact this gate does not recognise was seen at all",
50
+ ]);
51
+
52
+ /**
53
+ * What the answer actually establishes. The verb has to match the verdict: a refusal was refused, an
54
+ * approval request was NOT — writing "refused" on an approval would be the gate overstating its own
55
+ * answer in the one sentence meant to bound it.
56
+ */
57
+ const proves = (op, verb) =>
58
+ `Proves: the change set as this call would leave it was ${verb} by CodeRifts under operation "${op}".`;
59
+
60
+ const limits = () => DOES_NOT_PROVE.map((l) => ` - ${l}`).join("\n");
61
+
62
+ /** Build the refusal text an agent will actually read. */
63
+ export function blockText(op, why) {
64
+ return [
65
+ `CodeRifts refused this contract change.`,
66
+ why ? `Reason: ${why}` : null,
67
+ proves(op, "refused"),
68
+ `Does not prove:`,
69
+ limits(),
70
+ ]
71
+ .filter(Boolean)
72
+ .join("\n");
73
+ }
74
+
75
+ /** One JSON-RPC POST. No session handshake, no SDK — measured to be all the surface needs. */
76
+ async function askCodeRifts({ endpoint, apiKey, timeoutMs, operation, artifact }) {
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
79
+ try {
80
+ const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
81
+ if (apiKey) headers["X-API-Key"] = apiKey;
82
+ const res = await fetch(endpoint, {
83
+ method: "POST",
84
+ headers,
85
+ signal: controller.signal,
86
+ body: JSON.stringify({
87
+ jsonrpc: "2.0",
88
+ id: 1,
89
+ method: "tools/call",
90
+ params: {
91
+ name: "preflight_change_set",
92
+ arguments: {
93
+ // authorize is the only mode that carries a decision. Without a key the server answers
94
+ // authorization_requires_issuer, so an unkeyed gate asks in analyze mode and then has
95
+ // to fall to approval — see decide().
96
+ preflight_mode: apiKey ? "authorize" : "analyze",
97
+ context: { operation },
98
+ artifacts: [artifact],
99
+ },
100
+ },
101
+ }),
102
+ });
103
+ if (!res.ok) return { kind: "unreachable", detail: `HTTP ${res.status}` };
104
+ const env = await res.json();
105
+ if (env.error) return { kind: "unusable", detail: env.error.message ?? "JSON-RPC error" };
106
+ const text = env?.result?.content?.[0]?.text;
107
+ if (typeof text !== "string") return { kind: "unusable", detail: "no content in response" };
108
+ let body;
109
+ try {
110
+ body = JSON.parse(text);
111
+ } catch {
112
+ return { kind: "unusable", detail: "response was not JSON" };
113
+ }
114
+ if (body?.error) return { kind: "unusable", detail: body.message ?? body.error };
115
+ return { kind: "ok", body };
116
+ } catch (err) {
117
+ const aborted = err?.name === "AbortError";
118
+ return { kind: "unreachable", detail: aborted ? `no answer within ${timeoutMs}ms` : String(err?.message ?? err) };
119
+ }
120
+ }
121
+
122
+ /** Ask for a human, always with a reason. Never `{}`. */
123
+ const ask = (title, description) => ({
124
+ requireApproval: {
125
+ title,
126
+ description,
127
+ severity: "warning",
128
+ // Explicit "deny", not because it is honoured everywhere — on current OpenClaw unresolved
129
+ // approvals always deny and this field is deprecated — but because on the extended-stable line
130
+ // it is still read, and "allow" there would be a genuine fail-open.
131
+ timeoutBehavior: "deny",
132
+ },
133
+ });
134
+
135
+ /**
136
+ * Turn one CodeRifts answer into one OpenClaw hook result.
137
+ *
138
+ * The three shapes are the host's, measured: `undefined` passes, `{block, blockReason}` refuses,
139
+ * `{requireApproval}` asks. There is no fourth, and no default pass.
140
+ */
141
+ export function decide(outcome, { operation, path }) {
142
+ if (outcome.kind === "unreachable") {
143
+ return ask(
144
+ "CodeRifts did not answer",
145
+ `${path} is a contract artifact and CodeRifts could not be reached (${outcome.detail}). ` +
146
+ `No decision was obtained, and not knowing is not permission.`,
147
+ );
148
+ }
149
+ if (outcome.kind === "unusable") {
150
+ return ask(
151
+ "CodeRifts answer could not be read",
152
+ `${path} is a contract artifact and CodeRifts replied with something this gate cannot interpret ` +
153
+ `(${outcome.detail}). No decision was obtained, and not knowing is not permission.`,
154
+ );
155
+ }
156
+
157
+ const body = outcome.body;
158
+
159
+ // The analyze path says so about itself. Reading its risk number as a verdict would be this
160
+ // plugin inventing a policy engine, which is the one thing it must not do.
161
+ if (body.authorization_effect === "NONE" || body.may_execute === false && !body.execution_action) {
162
+ return ask(
163
+ "CodeRifts returned analysis, not authorization",
164
+ `${path} is a contract artifact. Without an API key CodeRifts answers in analyze mode ` +
165
+ `(authorization_effect=${String(body.authorization_effect)}, may_execute=${String(body.may_execute)}), ` +
166
+ `which carries risk information and no decision` +
167
+ (body.risk_score !== undefined ? ` (risk_score ${body.risk_score}` +
168
+ (Array.isArray(body.patterns) && body.patterns.length ? `, ${body.patterns.join(", ")}` : "") + `)` : "") +
169
+ `. Configure apiKey to get a decision; until then every gated call asks.`,
170
+ );
171
+ }
172
+
173
+ switch (body.execution_action) {
174
+ case "CONTINUE":
175
+ case "CONTINUE_WITH_MONITORING":
176
+ return undefined;
177
+ case "REQUEST_APPROVAL":
178
+ return ask(
179
+ "CodeRifts asks for approval",
180
+ `${path} — CodeRifts returned ${body.execution_action} for operation "${operation}"` +
181
+ (body.risk_score !== undefined ? `, risk_score ${body.risk_score}` : "") +
182
+ (Array.isArray(body.patterns) && body.patterns.length ? `, ${body.patterns.join(", ")}` : "") +
183
+ `.\n${proves(operation, "put to a human")}\nDoes not prove:\n${limits()}`,
184
+ );
185
+ case "STOP":
186
+ return {
187
+ block: true,
188
+ blockReason: blockText(
189
+ operation,
190
+ [
191
+ body.decision_basis?.rule_ids?.join(", "),
192
+ Array.isArray(body.patterns) && body.patterns.length ? body.patterns.join(", ") : null,
193
+ ]
194
+ .filter(Boolean)
195
+ .join(" — ") || undefined,
196
+ ),
197
+ };
198
+ default:
199
+ return ask(
200
+ "CodeRifts returned an action this gate does not know",
201
+ `${path} — execution_action was ${JSON.stringify(body.execution_action)}. ` +
202
+ `An unrecognised action is not a pass.`,
203
+ );
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Resolve the file this tool call would write, if any.
209
+ *
210
+ * `derivedPaths` is the host's own hint and is documented as best-effort, so it is used to widen the
211
+ * search, never to replace reading `params`.
212
+ */
213
+ export function resolveTarget(event, shapes) {
214
+ const shape = shapes[event.toolName];
215
+ const fromParams = shape ? event.params?.[shape.path] : undefined;
216
+ const candidates = [fromParams, ...(event.derivedPaths ?? [])].filter((p) => typeof p === "string" && p);
217
+ for (const p of candidates) {
218
+ const type = classifyPath(p);
219
+ if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined };
220
+ }
221
+ return null;
222
+ }
223
+
224
+ /** The handler, with its I/O injected so a test can drive it without a network or a disk. */
225
+ export function createGate(config = {}, deps = {}) {
226
+ const endpoint = config.endpoint ?? "https://app.coderifts.com/mcp";
227
+ const apiKey = config.apiKey;
228
+ const timeoutMs = config.timeoutMs ?? 5000;
229
+ const operation = config.operation ?? "tool_call";
230
+ const shapes = { ...DEFAULT_TOOL_SHAPES, ...(config.toolShapes ?? {}) };
231
+ const call = deps.askCodeRifts ?? askCodeRifts;
232
+ const read = deps.readFile ?? ((p) => readFile(p, "utf8"));
233
+
234
+ return async function beforeToolCall(event) {
235
+ const target = resolveTarget(event, shapes);
236
+ // Not a contract artifact this gate recognises. Staying out of the way is not a fail-open: the
237
+ // gate never claimed this call, and DOES_NOT_PROVE says as much on every refusal it does make.
238
+ if (!target) return undefined;
239
+ if (typeof target.content !== "string") {
240
+ return ask(
241
+ "CodeRifts gate could not read the proposed change",
242
+ `${target.path} is a contract artifact, but this gate could not find the new content in the ` +
243
+ `params of "${event.toolName}". It will not pass a change it has not seen.`,
244
+ );
245
+ }
246
+
247
+ let before = "";
248
+ try {
249
+ before = await read(target.path);
250
+ } catch {
251
+ before = ""; // A new file. An empty "before" is a real change set, not a missing one.
252
+ }
253
+
254
+ const outcome = await call({
255
+ endpoint,
256
+ apiKey,
257
+ timeoutMs,
258
+ operation,
259
+ artifact: { id: target.path, type: target.type, before, after: target.content },
260
+ });
261
+ return decide(outcome, { operation, path: target.path });
262
+ };
263
+ }
@@ -0,0 +1,18 @@
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.",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "Write|Edit|MultiEdit",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "timeout": 8,
11
+ "command": "node",
12
+ "args": ["${CLAUDE_PLUGIN_ROOT}/claude-code/hook.mjs"]
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ }
package/index.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * CodeRifts contract gate for OpenClaw.
3
+ *
4
+ * MEASURED SHAPE (openclaw 2026.6.35 installed, main source read 2026-09-13):
5
+ *
6
+ * api.on("before_tool_call", handler, { priority })
7
+ * handler(event, ctx) -> undefined | { params } | { block, blockReason } | { requireApproval }
8
+ *
9
+ * Handlers run sequentially in DESCENDING priority; same priority keeps registration order. The
10
+ * first `block: true` stops the chain. `block` is sticky, so an earlier plugin passing the call
11
+ * through does NOT stop this one from refusing. The FIRST `requireApproval` wins, so if another
12
+ * plugin already asked, ours is dropped — our `block` still applies.
13
+ *
14
+ * The host declares before_tool_call fail-closed (failurePolicyByHook) with a 15000ms budget, and
15
+ * a thrown handler error is NOT swallowed: it propagates and the tool call fails. Verified live
16
+ * against the host's own hook runner, all eight cases.
17
+ *
18
+ * PRIORITY 100 is deliberate. This gate wants to run before a plugin that might request approval
19
+ * for its own reasons, because only the first requireApproval survives the merge, and a refusal
20
+ * that names the contract change is more useful to a human than one that does not.
21
+ */
22
+
23
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
24
+ import { createGate } from "./gate.js";
25
+
26
+ export default definePluginEntry({
27
+ id: "coderifts-contract-gate",
28
+ name: "CodeRifts contract gate",
29
+ register(api) {
30
+ // api.pluginConfig, NOT api.config. Measured: `api.config` is the whole OpenClaw config
31
+ // snapshot; ours is `plugins.entries.<id>.config`. Reading the wrong one is silent — apiKey
32
+ // would simply never be found, and the gate would run unkeyed forever while looking configured.
33
+ const gate = createGate(api.pluginConfig ?? {});
34
+ api.on(
35
+ "before_tool_call",
36
+ async (event) => {
37
+ try {
38
+ return await gate(event);
39
+ } catch (err) {
40
+ // Never throw out of the handler. The host would turn this into an opaque tool failure;
41
+ // an approval request with a reason is the same safety with a readable cause.
42
+ return {
43
+ requireApproval: {
44
+ title: "CodeRifts gate failed",
45
+ description:
46
+ `The contract gate raised an error before it could reach a decision ` +
47
+ `(${String(err?.message ?? err)}). Not knowing is not permission.`,
48
+ severity: "warning",
49
+ timeoutBehavior: "deny",
50
+ },
51
+ };
52
+ }
53
+ },
54
+ { priority: 100 },
55
+ );
56
+ },
57
+ });
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "coderifts-contract-gate",
3
+ "name": "CodeRifts contract gate",
4
+ "description": "Ask CodeRifts before an agent writes a contract artifact.",
5
+ "version": "0.2.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "apiKey": {
11
+ "type": "string",
12
+ "description": "CodeRifts API key. WITHOUT IT THE GATE CANNOT OBTAIN A DECISION: the keyless path returns risk information only (authorization_effect NONE, may_execute false), so every gated call falls to approval."
13
+ },
14
+ "endpoint": {
15
+ "type": "string",
16
+ "description": "CodeRifts MCP endpoint. Default https://app.coderifts.com/mcp"
17
+ },
18
+ "timeoutMs": {
19
+ "type": "number",
20
+ "description": "Budget for the CodeRifts call. Default 5000. Keep it well under the host's 15000ms before_tool_call budget so a slow answer becomes a named approval request rather than an opaque host denial."
21
+ },
22
+ "operation": {
23
+ "type": "string",
24
+ "description": "context.operation sent with the change set. Default tool_call."
25
+ }
26
+ }
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@coderifts/agent-hooks",
3
+ "version": "0.2.0",
4
+ "description": "Ask CodeRifts before an agent writes a contract artifact (OpenClaw before_tool_call and Claude Code PreToolUse).",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/coderifts/agent-hooks#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/coderifts/agent-hooks.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/coderifts/agent-hooks/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "./index.js",
16
+ "files": [
17
+ "index.js",
18
+ "gate.js",
19
+ "openclaw.plugin.json",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "claude-code/hook.mjs",
23
+ "claude-code/settings.snippet.json",
24
+ "claude-code/marketplace-entry.json",
25
+ "hooks/hooks.json",
26
+ ".claude-plugin/plugin.json"
27
+ ],
28
+ "peerDependencies": {
29
+ "openclaw": ">=2026.6.35"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "openclaw": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "openclaw": {
37
+ "extensions": ["./index.js"],
38
+ "compat": {
39
+ "pluginApi": ">=2026.6.35"
40
+ },
41
+ "build": {
42
+ "openclawVersion": "2026.6.35"
43
+ }
44
+ },
45
+ "scripts": {
46
+ "test": "node --test test/*.test.js"
47
+ }
48
+ }