@coderifts/agent-hooks 0.3.3 → 0.3.4
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/claude-code/marketplace-entry.json +1 -1
- package/contract-write.mjs +73 -16
- package/gate.js +8 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.4 — 2026-10-09
|
|
4
|
+
### Fixed
|
|
5
|
+
- **A plain `mcp.json` that is both an MCP client configuration and a tool manifest, or that does not parse, is not
|
|
6
|
+
read or sent and is not passed (contract-write 1.3.1).** On the Claude Code file tools and on the OpenClaw tool
|
|
7
|
+
shapes the gate asks, with one sentence: split the server list and the tool manifest into separate files, or make
|
|
8
|
+
the file valid JSON (no comments) to have it checked.
|
|
9
|
+
|
|
3
10
|
## 0.3.3 — 2026-10-08
|
|
4
11
|
### Fixed
|
|
5
12
|
- **A plain `mcp.json` is decided by its content (contract-write 1.3.0, P65c).** 0.3.2 skipped every `mcp.json` by
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"repo": "coderifts/agent-hooks"
|
|
6
6
|
},
|
|
7
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.3.
|
|
8
|
+
"version": "0.3.4",
|
|
9
9
|
"author": {
|
|
10
10
|
"name": "CodeRifts",
|
|
11
11
|
"email": "peter@coderifts.com"
|
package/contract-write.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// written by scripts/generate-contract-write-copies.js, byte for byte (the CommonJS twin is a
|
|
28
28
|
// mechanical transform), and its --check fails on any difference. Do not edit a copy.
|
|
29
29
|
|
|
30
|
-
export const CONTRACT_WRITE_VERSION = '1.3.
|
|
30
|
+
export const CONTRACT_WRITE_VERSION = '1.3.1';
|
|
31
31
|
|
|
32
32
|
/** What a shell write to a named contract file gets, and what an unnamed one gets. */
|
|
33
33
|
export const SHELL_NAMED_DECISION = 'refuse';
|
|
@@ -65,26 +65,56 @@ export const MCP_JSON_BY_CONTENT = /(^|\/)mcp\.json$/i;
|
|
|
65
65
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
* `
|
|
70
|
-
*
|
|
68
|
+
* The kind of a plain mcp.json's text, from its parsed TOP-LEVEL object (a leading BOM is ignored):
|
|
69
|
+
* 'client_config' `mcpServers` or `servers` (an object) and no `tools`: never sent, counts as no file
|
|
70
|
+
* 'mixed' `mcpServers` or `servers` (an object) AND `tools`: never sent, and not passed (P65d)
|
|
71
|
+
* 'unparseable' JSON.parse fails (comments, a trailing comma, …): never sent, and not passed (P65d)
|
|
72
|
+
* 'contract' anything else: a `tools` key, an object without a server list, a parsed non-object
|
|
73
|
+
* 1.3.1 (P65d, 2026-10-09): 1.3.0 checked `tools` first and called a parse failure a contract, so a server
|
|
74
|
+
* list with a `tools` key, and a client configuration with a comment, were sent as contracts.
|
|
71
75
|
*/
|
|
72
76
|
export function mcpJsonKind(text) {
|
|
73
77
|
let doc;
|
|
74
78
|
try {
|
|
75
79
|
doc = JSON.parse(String(text ?? '').replace(/^\uFEFF/, ''));
|
|
76
80
|
} catch {
|
|
77
|
-
return '
|
|
81
|
+
return 'unparseable';
|
|
78
82
|
}
|
|
79
|
-
if (!isObject(doc)
|
|
80
|
-
|
|
83
|
+
if (!isObject(doc)) return 'contract';
|
|
84
|
+
const servers = isObject(doc.mcpServers) || isObject(doc.servers);
|
|
85
|
+
if (servers) return 'tools' in doc ? 'mixed' : 'client_config';
|
|
86
|
+
return 'contract';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The kind of one present side of a content-decided path; null for any other path or an empty side. */
|
|
90
|
+
export function mcpJsonContentKind(path, text) {
|
|
91
|
+
if (typeof text !== 'string' || text === '') return null;
|
|
92
|
+
const rel = normalizePath(path);
|
|
93
|
+
if (!MCP_JSON_BY_CONTENT.test(rel) || MCP_CLIENT_CONFIG.test(rel)) return null;
|
|
94
|
+
return mcpJsonKind(text);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/** True when `path` is decided by content and `text` (one side, present) is a client configuration. */
|
|
84
98
|
export function isClientConfigContent(path, text) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
return mcpJsonContentKind(path, text) === 'client_config';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 'mixed' or 'unparseable': never sent, and never passed silently — the hooks ask, the check is red. */
|
|
103
|
+
export function isHeldContent(path, text) {
|
|
104
|
+
const k = mcpJsonContentKind(path, text);
|
|
105
|
+
return k === 'mixed' || k === 'unparseable';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every side whose text no entry point sends: a client configuration, 'mixed', 'unparseable'. */
|
|
109
|
+
export function isNeverSent(path, text) {
|
|
110
|
+
return isClientConfigContent(path, text) || isHeldContent(path, text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The one sentence every entry point says for a held side; null for any other kind. */
|
|
114
|
+
export function heldWhy(path, kind) {
|
|
115
|
+
if (kind === 'mixed') return `${path} is both an MCP client configuration and a tool manifest; it is not read or sent — split the server list and the tool manifest into separate files.`;
|
|
116
|
+
if (kind === 'unparseable') return `${path} does not parse as JSON; it is not read or sent — a client configuration may hold credentials. Make it valid JSON (no comments) to have it checked.`;
|
|
117
|
+
return null;
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
export function looksLikeContractPath(p) {
|
|
@@ -720,10 +750,16 @@ export async function decideToolCall(call, io = {}) {
|
|
|
720
750
|
} catch (err) {
|
|
721
751
|
return { action: 'refuse', reason: 'unreadable', rel, type, why: `${rel} is a contract file and could not be read (${String((err && err.message) || err)})` };
|
|
722
752
|
}
|
|
753
|
+
// 1.3.1 (P65d): a held side ('mixed', 'unparseable') — the file on disk, or the text the call writes —
|
|
754
|
+
// is not sent and not passed: the hooks ask, with the sentence. Nothing of it is in the answer.
|
|
755
|
+
const heldBefore = mcpJsonContentKind(rel, before);
|
|
756
|
+
if (heldBefore === 'mixed' || heldBefore === 'unparseable') return held(rel, type, heldBefore);
|
|
723
757
|
const after = afterText(kind, input, before);
|
|
724
758
|
if (after === null) {
|
|
725
759
|
return { action: 'refuse', reason: 'edit_does_not_apply', rel, type, why: `the ${kind} does not apply to ${rel} as it is on disk (${afterFailure(kind, input, before)}), so there is no after text to check` };
|
|
726
760
|
}
|
|
761
|
+
const heldAfter = mcpJsonContentKind(rel, after);
|
|
762
|
+
if (heldAfter === 'mixed' || heldAfter === 'unparseable') return held(rel, type, heldAfter);
|
|
727
763
|
// 1.3.0 (P65c): a plain mcp.json is decided by content, here, before anything is sent. A side that is
|
|
728
764
|
// a client configuration counts as no file: both such → pass; one → the manifest added or removed.
|
|
729
765
|
const clientBefore = isClientConfigContent(rel, before);
|
|
@@ -737,14 +773,22 @@ export async function decideToolCall(call, io = {}) {
|
|
|
737
773
|
return { action: 'gate', path: filePath, rel, type, before, after };
|
|
738
774
|
}
|
|
739
775
|
|
|
740
|
-
/**
|
|
741
|
-
|
|
742
|
-
|
|
776
|
+
/** The answer for a held side: ask, with the sentence; no text of the file. */
|
|
777
|
+
function held(rel, type, kind) {
|
|
778
|
+
return { action: 'ask', reason: 'mcp_json_held', rel, type, kind, scopes: [rel], why: heldWhy(rel, kind) };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The content kind of a plain mcp.json on disk, for a shell write that names it (null: not content-
|
|
783
|
+
* decided, missing, or unreadable — those stay the contract write they were).
|
|
784
|
+
*/
|
|
785
|
+
async function contentKindOnDisk(rel, io) {
|
|
786
|
+
if (!MCP_JSON_BY_CONTENT.test(rel) || !io.readFile) return null;
|
|
743
787
|
const root = normalizePath(io.cwd || '');
|
|
744
788
|
try {
|
|
745
|
-
return
|
|
789
|
+
return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel));
|
|
746
790
|
} catch {
|
|
747
|
-
return
|
|
791
|
+
return null;
|
|
748
792
|
}
|
|
749
793
|
}
|
|
750
794
|
|
|
@@ -770,12 +814,16 @@ async function mayHoldContract(scope, io, opts) {
|
|
|
770
814
|
|
|
771
815
|
async function decideShell(command, io, opts) {
|
|
772
816
|
const named = [];
|
|
817
|
+
const heldPaths = [];
|
|
773
818
|
const scopes = new Map();
|
|
774
819
|
for (const { target, by } of shellWrites(command)) {
|
|
775
820
|
const inTree = scopeInTree(target, io);
|
|
776
821
|
if (inTree === null) continue;
|
|
777
822
|
const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
|
|
778
|
-
|
|
823
|
+
const onDisk = type ? await contentKindOnDisk(inTree, io) : null;
|
|
824
|
+
if (onDisk === 'client_config') continue;
|
|
825
|
+
// 1.3.1 (P65d): a held file on disk is not a contract write to refuse, and not a pass: ask.
|
|
826
|
+
if (onDisk === 'mixed' || onDisk === 'unparseable') { heldPaths.push({ rel: inTree, kind: onDisk }); continue; }
|
|
779
827
|
if (type) named.push({ path: target, type, by });
|
|
780
828
|
else scopes.set(inTree, [...(scopes.get(inTree) || []), by]);
|
|
781
829
|
}
|
|
@@ -787,6 +835,15 @@ async function decideShell(command, io, opts) {
|
|
|
787
835
|
why: `this shell command writes ${named.map((n) => `${n.path} (${n.by})`).join(', ')}, and a shell write cannot be checked: the after text is not known. Use the Write or Edit tool for contract files`,
|
|
788
836
|
};
|
|
789
837
|
}
|
|
838
|
+
if (heldPaths.length) {
|
|
839
|
+
return {
|
|
840
|
+
action: 'ask',
|
|
841
|
+
reason: 'mcp_json_held',
|
|
842
|
+
kind: heldPaths[0].kind,
|
|
843
|
+
scopes: heldPaths.map((h) => h.rel),
|
|
844
|
+
why: heldPaths.map((h) => heldWhy(h.rel, h.kind)).join(' '),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
790
847
|
const reached = [];
|
|
791
848
|
for (const [scope, bys] of scopes) {
|
|
792
849
|
if (await mayHoldContract(scope, io, opts)) reached.push({ scope, by: [...new Set(bys)] });
|
package/gate.js
CHANGED
|
@@ -11,7 +11,7 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
11
11
|
// command writes. A byte copy of @coderifts/contract-path's contract-write.mjs, written by the app's
|
|
12
12
|
// scripts/generate-contract-write-copies.js; contract-write.sha256 beside it is checked by
|
|
13
13
|
// test/contract-write-copy.test.js.
|
|
14
|
-
import { contractType, decideToolCall, isClientConfigContent, toolKind } from "./contract-write.mjs";
|
|
14
|
+
import { contractType, decideToolCall, heldWhy, isClientConfigContent, mcpJsonContentKind, toolKind } from "./contract-write.mjs";
|
|
15
15
|
|
|
16
16
|
/** Tools whose params carry a path and a new file body. */
|
|
17
17
|
export const DEFAULT_TOOL_SHAPES = Object.freeze({
|
|
@@ -264,6 +264,9 @@ async function defaultListDir(dir) {
|
|
|
264
264
|
async function gateClaudeCall(event, { read, call, endpoint, apiKey, timeoutMs, operation }) {
|
|
265
265
|
const d = await decideToolCall({ tool: event.toolName, input: event.params ?? {} }, hostIo(event.cwd, read));
|
|
266
266
|
if (d.action === "pass") return undefined;
|
|
267
|
+
// 0.3.4 (P65d): a plain mcp.json that is both a client configuration and a tool manifest, or that does
|
|
268
|
+
// not parse — contract-write's 'mcp_json_held'. Not sent, not passed: the user is asked, with its sentence.
|
|
269
|
+
if (d.reason === "mcp_json_held") return ask("CodeRifts gate: this mcp.json is not checked", d.why);
|
|
267
270
|
if (d.action !== "gate") {
|
|
268
271
|
return ask("CodeRifts gate could not read the proposed change", `${d.why}. It will not pass a change it has not seen.`);
|
|
269
272
|
}
|
|
@@ -327,6 +330,10 @@ export function createGate(config = {}, deps = {}) {
|
|
|
327
330
|
// 0.3.3 (P65c): a plain mcp.json is decided by its content (contract-write's function), as on the
|
|
328
331
|
// Claude Code path. A side that is an MCP client configuration counts as no file and is never sent;
|
|
329
332
|
// both such sides → this gate never claimed the call.
|
|
333
|
+
// 0.3.4 (P65d): a held side ('mixed' / 'unparseable') is never sent and not passed: ask, with the sentence.
|
|
334
|
+
const heldKind = [before, after].map((t) => mcpJsonContentKind(target.path, t)).find((k) => k === "mixed" || k === "unparseable");
|
|
335
|
+
if (heldKind) return ask("CodeRifts gate: this mcp.json is not checked", heldWhy(target.path, heldKind));
|
|
336
|
+
|
|
330
337
|
const clientBefore = isClientConfigContent(target.path, before);
|
|
331
338
|
const clientAfter = isClientConfigContent(target.path, after);
|
|
332
339
|
const sentBefore = clientBefore ? "" : before;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderifts/agent-hooks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|