Repository navigation
Expand file tree
/
Copy pathgate.js
More file actions
352 lines (326 loc) · 16.3 KB
/
Copy pathgate.js
File metadata and controls
352 lines (326 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
/**
* The gate itself, with no OpenClaw imports, so it can be exercised directly.
*
* `index.js` is the OpenClaw entry; everything decidable lives here.
*/
import { readFile, readdir } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
// The one decision function this hook, `coderifts claude-hook` and the CodeRifts mod share
// (2026-10-03): which path is a contract and of what type, the after text of an Edit, what a shell
// command writes. A byte copy of @coderifts/contract-path's contract-write.mjs, written by the app's
// scripts/generate-contract-write-copies.js; contract-write.sha256 beside it is checked by
// test/contract-write-copy.test.js.
import { contractType, decideToolCall, heldWhy, isClientConfigContent, mcpJsonContentKind, toolKind } from "./contract-write.mjs";
/** Tools whose params carry a path and a new file body. */
export const DEFAULT_TOOL_SHAPES = Object.freeze({
write_file: { path: "path", content: "content" },
create_file: { path: "path", content: "content" },
edit_file: { path: "path", content: "content" },
str_replace_editor: { path: "path", content: "new_str" },
});
/**
* The contract type of a path (contract-write's list: OpenAPI/Swagger, AsyncAPI, GraphQL, protobuf,
* MCP manifests and tool lists, agent tool schemas), or null. Until 0.3.0 this file kept its own
* list and sent `protobuf` and `mcp`, which the change-set surface does not analyze; the types are
* now the surface's own (`grpc`, `mcp_manifest`). What is not on the list is not gated, and the
* refusal text says so.
*/
export function classifyPath(p) {
return contractType(p);
}
/**
* What this gate proves, and what it does not. Carried on EVERY refusal.
*
* An agent that reads "DENIED" and nothing else learns only that something said no. These lines are
* the difference between a policy engine's verdict and a reviewable one: they tell the agent — and
* the human reading the transcript — exactly how far the answer reaches.
*/
export const DOES_NOT_PROVE = Object.freeze([
"that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write",
"that the other tool calls in this run were checked — each call is judged alone",
"that a contract artifact this gate does not recognise was seen at all",
"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, and a plugin hook runs after the mods even when managed settings force-enable the plugin",
"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)",
// 0.3.0 (2026-10-03), measured on Claude Code 2.1.288 with a probe plugin: disableAllHooks from
// --settings and from the project's .claude/settings.json, --safe-mode and --bare each stopped a
// plugin's PreToolUse hook; a shell command rewrote .claude/settings.json unless the sandbox was on.
"that the hook was running — disableAllHooks in any settings file (the project's .claude/settings.json included, which a shell command can rewrite unless the sandbox protects it), --safe-mode or --bare turns it off",
"that a contract generated from source code was checked — a contract generated from source code (annotations, decorators, a build step) changes when the source changes; the hooks see the source edit, not the contract; the required check sees the generated contract",
]);
/**
* What the answer actually establishes. The verb has to match the verdict: a refusal was refused, an
* approval request was NOT — writing "refused" on an approval would be the gate overstating its own
* answer in the one sentence meant to bound it.
*/
const proves = (op, verb) =>
`Proves: the change set as this call would leave it was ${verb} by CodeRifts under operation "${op}".`;
const limits = () => DOES_NOT_PROVE.map((l) => ` - ${l}`).join("\n");
/** Build the refusal text an agent will actually read. */
export function blockText(op, why) {
return [
`CodeRifts refused this contract change.`,
why ? `Reason: ${why}` : null,
proves(op, "refused"),
`Does not prove:`,
limits(),
]
.filter(Boolean)
.join("\n");
}
/** One JSON-RPC POST. No session handshake, no SDK — measured to be all the surface needs. */
export async function askCodeRifts({ endpoint, apiKey, timeoutMs, operation, artifact }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
if (apiKey) headers["X-API-Key"] = apiKey;
const res = await fetch(endpoint, {
method: "POST",
headers,
signal: controller.signal,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "preflight_change_set",
arguments: {
// authorize is the only mode that carries a decision. Without a key the server answers
// authorization_requires_issuer, so an unkeyed gate asks in analyze mode and then has
// to fall to approval — see decide().
preflight_mode: apiKey ? "authorize" : "analyze",
context: { operation },
artifacts: [artifact],
},
},
}),
});
if (!res.ok) return { kind: "unreachable", detail: `HTTP ${res.status}` };
const env = await res.json();
if (env.error) return { kind: "unusable", detail: env.error.message ?? "JSON-RPC error" };
const text = env?.result?.content?.[0]?.text;
if (typeof text !== "string") return { kind: "unusable", detail: "no content in response" };
let body;
try {
body = JSON.parse(text);
} catch {
return { kind: "unusable", detail: "response was not JSON" };
}
if (body?.error) return { kind: "unusable", detail: body.message ?? body.error };
return { kind: "ok", body };
} catch (err) {
const aborted = err?.name === "AbortError";
return { kind: "unreachable", detail: aborted ? `no answer within ${timeoutMs}ms` : String(err?.message ?? err) };
}
}
/**
* GOVERNANCE_UNAVAILABLE (2026-10-04): the one sentence the App, the CLI hook and the Claude Code mod
* write verbatim when CodeRifts could not decide. The call still stops; the sentence says the stop is
* about CodeRifts, not about the change.
*/
export function governanceUnavailable(why) {
return `GOVERNANCE_UNAVAILABLE: CodeRifts could not decide (${why}); this is not a finding about your change.`;
}
/** Ask for a human, always with a reason. Never `{}`. */
const ask = (title, description) => ({
requireApproval: {
title,
description,
severity: "warning",
// Explicit "deny", not because it is honoured everywhere — on current OpenClaw unresolved
// approvals always deny and this field is deprecated — but because on the extended-stable line
// it is still read, and "allow" there would be a genuine fail-open.
timeoutBehavior: "deny",
},
});
/**
* Turn one CodeRifts answer into one OpenClaw hook result.
*
* The three shapes are the host's, measured: `undefined` passes, `{block, blockReason}` refuses,
* `{requireApproval}` asks. There is no fourth, and no default pass.
*/
export function decide(outcome, { operation, path }) {
if (outcome.kind === "unreachable") {
return ask(
"CodeRifts did not answer",
`${path} is a contract artifact and CodeRifts could not be reached (${outcome.detail}). ` +
`${governanceUnavailable(`CodeRifts could not be reached: ${outcome.detail}`)} ` +
`No decision was obtained, and not knowing is not permission.`,
);
}
if (outcome.kind === "unusable") {
return ask(
"CodeRifts answer could not be read",
`${path} is a contract artifact and CodeRifts replied with something this gate cannot interpret ` +
`(${outcome.detail}). ${governanceUnavailable(`the answer could not be read: ${outcome.detail}`)} ` +
`No decision was obtained, and not knowing is not permission.`,
);
}
const body = outcome.body;
// The analyze path says so about itself. Reading its risk number as a verdict would be this
// plugin inventing a policy engine, which is the one thing it must not do.
if (body.authorization_effect === "NONE" || body.may_execute === false && !body.execution_action) {
return ask(
"CodeRifts returned analysis, not authorization",
`${path} is a contract artifact. Without an API key CodeRifts answers in analyze mode ` +
`(authorization_effect=${String(body.authorization_effect)}, may_execute=${String(body.may_execute)}), ` +
`which carries risk information and no decision` +
(body.risk_score !== undefined ? ` (risk_score ${body.risk_score}` +
(Array.isArray(body.patterns) && body.patterns.length ? `, ${body.patterns.join(", ")}` : "") + `)` : "") +
`. Configure apiKey to get a decision; until then every gated call asks.`,
);
}
switch (body.execution_action) {
case "CONTINUE":
case "CONTINUE_WITH_MONITORING":
return undefined;
case "REQUEST_APPROVAL":
return ask(
"CodeRifts asks for approval",
`${path} — CodeRifts returned ${body.execution_action} for operation "${operation}"` +
(body.risk_score !== undefined ? `, risk_score ${body.risk_score}` : "") +
(Array.isArray(body.patterns) && body.patterns.length ? `, ${body.patterns.join(", ")}` : "") +
`.\n${proves(operation, "put to a human")}\nDoes not prove:\n${limits()}`,
);
case "STOP":
return {
block: true,
blockReason: blockText(
operation,
[
body.decision_basis?.rule_ids?.join(", "),
Array.isArray(body.patterns) && body.patterns.length ? body.patterns.join(", ") : null,
]
.filter(Boolean)
.join(" — ") || undefined,
),
};
default:
return ask(
"CodeRifts returned an action this gate does not know",
`${path} — execution_action was ${JSON.stringify(body.execution_action)}. ` +
`An unrecognised action is not a pass.`,
);
}
}
/**
* Resolve the file this tool call would write, if any.
*
* `derivedPaths` is the host's own hint and is documented as best-effort, so it is used to widen the
* search, never to replace reading `params`.
*/
export function resolveTarget(event, shapes) {
const shape = shapes[event.toolName];
const fromParams = shape ? event.params?.[shape.path] : undefined;
const candidates = [fromParams, ...(event.derivedPaths ?? [])].filter((p) => typeof p === "string" && p);
for (const p of candidates) {
const type = classifyPath(p);
if (type) return { path: p, type, content: shape ? event.params?.[shape.content] : undefined, apply: shape?.apply };
}
return null;
}
/** The host readers contract-write takes: a missing file is null, an unreadable one throws. */
export function hostIo(cwd, read = (p) => readFile(p, "utf8"), list = defaultListDir) {
const abs = (p) => (isAbsolute(p) ? p : resolve(cwd || process.cwd(), p));
return {
cwd: cwd || process.cwd(),
readFile: async (p) => {
try {
return await read(abs(p));
} catch (err) {
if (err && err.code === "ENOENT") return null;
throw err;
}
},
listDir: (dir) => list(abs(dir)),
};
}
async function defaultListDir(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.map((e) => ({ name: e.name, kind: e.isDirectory() ? "directory" : "file" }));
} catch {
return null;
}
}
async function gateClaudeCall(event, { read, call, endpoint, apiKey, timeoutMs, operation }) {
const d = await decideToolCall({ tool: event.toolName, input: event.params ?? {} }, hostIo(event.cwd, read));
if (d.action === "pass") return undefined;
// 0.3.4 (P65d): a plain mcp.json that is both a client configuration and a tool manifest, or that does
// not parse — contract-write's 'mcp_json_held'. Not sent, not passed: the user is asked, with its sentence.
if (d.reason === "mcp_json_held") return ask("CodeRifts gate: this mcp.json is not checked", d.why);
if (d.action !== "gate") {
return ask("CodeRifts gate could not read the proposed change", `${d.why}. It will not pass a change it has not seen.`);
}
const outcome = await call({
endpoint,
apiKey,
timeoutMs,
operation,
artifact: { id: d.path, type: d.type, before: d.before, after: d.after },
});
return decide(outcome, { operation, path: d.path });
}
/** The handler, with its I/O injected so a test can drive it without a network or a disk. */
export function createGate(config = {}, deps = {}) {
const endpoint = config.endpoint ?? "https://app.coderifts.com/mcp";
const apiKey = config.apiKey;
const timeoutMs = config.timeoutMs ?? 5000;
const operation = config.operation ?? "tool_call";
const shapes = { ...DEFAULT_TOOL_SHAPES, ...(config.toolShapes ?? {}) };
const call = deps.askCodeRifts ?? askCodeRifts;
const read = deps.readFile ?? ((p) => readFile(p, "utf8"));
return async function beforeToolCall(event) {
// A Claude Code file tool (Write / Edit / MultiEdit): contract-write decides the path, the type
// and the after text, as the CLI hook and the mod do.
const claudeTool = toolKind(event.toolName);
if (claudeTool && claudeTool !== "Bash") return gateClaudeCall(event, { read, call, endpoint, apiKey, timeoutMs, operation });
const target = resolveTarget(event, shapes);
// Not a contract artifact this gate recognises. Staying out of the way is not a fail-open: the
// gate never claimed this call, and DOES_NOT_PROVE says as much on every refusal it does make.
if (!target) return undefined;
if (!target.apply && typeof target.content !== "string") {
return ask(
"CodeRifts gate could not read the proposed change",
`${target.path} is a contract artifact, but this gate could not find the new content in the ` +
`params of "${event.toolName}". It will not pass a change it has not seen.`,
);
}
let before = "";
try {
before = await read(target.path);
} catch {
before = ""; // A new file. An empty "before" is a real change set, not a missing one.
}
// A tool whose params carry an edit, not a body (Claude Code Edit/MultiEdit): the after body is
// the file with the edit applied. An edit that does not apply is put to a human — sending a
// fragment as the whole file would read as "everything else was removed".
const after = target.apply ? target.apply(event.params ?? {}, before) : target.content;
if (typeof after !== "string") {
return ask(
"CodeRifts gate could not read the proposed change",
`${target.path} is a contract artifact, but the edit in "${event.toolName}" does not apply to ` +
`the file as it is on disk. It will not pass a change it has not seen.`,
);
}
// 0.3.3 (P65c): a plain mcp.json is decided by its content (contract-write's function), as on the
// Claude Code path. A side that is an MCP client configuration counts as no file and is never sent;
// both such sides → this gate never claimed the call.
// 0.3.4 (P65d): a held side ('mixed' / 'unparseable') is never sent and not passed: ask, with the sentence.
const heldKind = [before, after].map((t) => mcpJsonContentKind(target.path, t)).find((k) => k === "mixed" || k === "unparseable");
if (heldKind) return ask("CodeRifts gate: this mcp.json is not checked", heldWhy(target.path, heldKind));
const clientBefore = isClientConfigContent(target.path, before);
const clientAfter = isClientConfigContent(target.path, after);
const sentBefore = clientBefore ? "" : before;
const sentAfter = clientAfter ? "" : after;
if ((clientBefore || clientAfter) && sentBefore === "" && sentAfter === "") return undefined;
const outcome = await call({
endpoint,
apiKey,
timeoutMs,
operation,
artifact: { id: target.path, type: target.type, before: sentBefore, after: sentAfter },
});
return decide(outcome, { operation, path: target.path });
};
}