@coderifts/agent-hooks 0.3.2 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hooks",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Ask CodeRifts before Claude Code writes a contract artifact (OpenAPI, AsyncAPI, GraphQL, protobuf, MCP manifest).",
5
5
  "author": {
6
6
  "name": "CodeRifts",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
10
+ ## 0.3.3 — 2026-10-08
11
+ ### Fixed
12
+ - **A plain `mcp.json` is decided by its content (contract-write 1.3.0, P65c).** 0.3.2 skipped every `mcp.json` by
13
+ name, which also skipped a server's tool manifest of that name. By name only `.mcp.json`, `.cursor/mcp.json`,
14
+ `.vscode/mcp.json`, `claude_desktop_config.json` and `(cline_)mcp_settings.json` stay skipped (never read). Any
15
+ other `mcp.json` is read on disk: `mcpServers` / `servers` and no `tools` → a client configuration, nothing sent;
16
+ `tools`, any other shape, or unparseable → checked as a contract. A manifest rewritten into a client configuration
17
+ is checked as the manifest removed, without the client side. The same on the OpenClaw tool shapes (`write_file` …).
18
+
3
19
  ## 0.3.2 — 2026-10-06
4
20
  ### Fixed
5
21
  - **An MCP client configuration is never treated as a contract (contract-write 1.2.0, P65).** `.mcp.json`, `mcp.json`
@@ -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.2",
8
+ "version": "0.3.4",
9
9
  "author": {
10
10
  "name": "CodeRifts",
11
11
  "email": "peter@coderifts.com"
@@ -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.2.0';
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';
@@ -45,12 +45,77 @@ export const CONTRACT_EXT = /\.(ya?ml|json|graphql|gql|proto)$/i;
45
45
  * 1.2.0 (2026-10-06, the Claude directory's hold MCP_FORWARDS_CREDENTIAL_ENV): an MCP CLIENT
46
46
  * configuration file — the list of servers a client starts, with their `env` credentials — is not a
47
47
  * contract. `.mcp.json` and `mcp.json` matched the list above (".json" + "mcp") as mcp_manifest, so an
48
- * Edit of one sent its whole text, tokens included, to preflight. Decided by name, so the file is never
49
- * read to decide; it wins over the project's own `schema:` list too. MCP tool manifests are unchanged.
48
+ * Edit of one sent its whole text, tokens included, to preflight. MCP tool manifests are unchanged.
50
49
  * P65 (2026-10-06): the required check uses this same pattern — index.cjs requires it from here, and its
51
50
  * looksLikeContractPath says no first, word for word as below.
51
+ *
52
+ * 1.3.0 (P65c, 2026-10-07): by NAME only the names that are a client configuration and nothing else —
53
+ * `.mcp.json` anywhere, `.cursor/mcp.json`, `.vscode/mcp.json`, `claude_desktop_config.json`,
54
+ * `(cline_)mcp_settings.json`. Those are never read, and they win over the project's own `schema:` list.
55
+ * Any other `mcp.json` (MCP_JSON_BY_CONTENT) is also the name a server's tool manifest carries
56
+ * (coderifts.com's own), so it is a candidate, read where it already is, and decided by mcpJsonKind:
57
+ * the hooks read it on disk and never send a client configuration; the required check decides after its
58
+ * own read (isClientConfigContent), and a client-configuration side counts as no file at all.
52
59
  */
53
- export const MCP_CLIENT_CONFIG = /(^|\/)(\.?mcp\.json|claude_desktop_config\.json|(cline_)?mcp_settings\.json)$/i;
60
+ export const MCP_CLIENT_CONFIG = /(^|\/)(\.mcp\.json|\.cursor\/mcp\.json|\.vscode\/mcp\.json|claude_desktop_config\.json|(cline_)?mcp_settings\.json)$/i;
61
+
62
+ /** A plain `mcp.json` that MCP_CLIENT_CONFIG does not take by name: decided by its content. */
63
+ export const MCP_JSON_BY_CONTENT = /(^|\/)mcp\.json$/i;
64
+
65
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
66
+
67
+ /**
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.
75
+ */
76
+ export function mcpJsonKind(text) {
77
+ let doc;
78
+ try {
79
+ doc = JSON.parse(String(text ?? '').replace(/^\uFEFF/, ''));
80
+ } catch {
81
+ return 'unparseable';
82
+ }
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);
95
+ }
96
+
97
+ /** True when `path` is decided by content and `text` (one side, present) is a client configuration. */
98
+ export function isClientConfigContent(path, text) {
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;
118
+ }
54
119
 
55
120
  export function looksLikeContractPath(p) {
56
121
  const s = String(p || '').toLowerCase();
@@ -685,13 +750,48 @@ export async function decideToolCall(call, io = {}) {
685
750
  } catch (err) {
686
751
  return { action: 'refuse', reason: 'unreadable', rel, type, why: `${rel} is a contract file and could not be read (${String((err && err.message) || err)})` };
687
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);
688
757
  const after = afterText(kind, input, before);
689
758
  if (after === null) {
690
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` };
691
760
  }
761
+ const heldAfter = mcpJsonContentKind(rel, after);
762
+ if (heldAfter === 'mixed' || heldAfter === 'unparseable') return held(rel, type, heldAfter);
763
+ // 1.3.0 (P65c): a plain mcp.json is decided by content, here, before anything is sent. A side that is
764
+ // a client configuration counts as no file: both such → pass; one → the manifest added or removed.
765
+ const clientBefore = isClientConfigContent(rel, before);
766
+ const clientAfter = isClientConfigContent(rel, after);
767
+ if (clientBefore || clientAfter) {
768
+ const b = clientBefore ? '' : before;
769
+ const a = clientAfter ? '' : after;
770
+ if (b === '' && a === '') return { action: 'pass', reason: 'mcp_client_config', rel };
771
+ return { action: 'gate', path: filePath, rel, type, before: b, after: a };
772
+ }
692
773
  return { action: 'gate', path: filePath, rel, type, before, after };
693
774
  }
694
775
 
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;
787
+ const root = normalizePath(io.cwd || '');
788
+ try {
789
+ return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel));
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+
695
795
  /** Is there a contract file under `scope`? Unknown (no reader, a limit hit) counts as yes. */
696
796
  async function mayHoldContract(scope, io, opts) {
697
797
  const glob = /[*?]/.test(scope) ? globToRegExp(normalizePath(scope)) : null;
@@ -714,11 +814,16 @@ async function mayHoldContract(scope, io, opts) {
714
814
 
715
815
  async function decideShell(command, io, opts) {
716
816
  const named = [];
817
+ const heldPaths = [];
717
818
  const scopes = new Map();
718
819
  for (const { target, by } of shellWrites(command)) {
719
820
  const inTree = scopeInTree(target, io);
720
821
  if (inTree === null) continue;
721
822
  const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
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; }
722
827
  if (type) named.push({ path: target, type, by });
723
828
  else scopes.set(inTree, [...(scopes.get(inTree) || []), by]);
724
829
  }
@@ -730,6 +835,15 @@ async function decideShell(command, io, opts) {
730
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`,
731
836
  };
732
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
+ }
733
847
  const reached = [];
734
848
  for (const [scope, bys] of scopes) {
735
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, 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
  }
@@ -324,12 +327,25 @@ export function createGate(config = {}, deps = {}) {
324
327
  );
325
328
  }
326
329
 
330
+ // 0.3.3 (P65c): a plain mcp.json is decided by its content (contract-write's function), as on the
331
+ // Claude Code path. A side that is an MCP client configuration counts as no file and is never sent;
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
+
337
+ const clientBefore = isClientConfigContent(target.path, before);
338
+ const clientAfter = isClientConfigContent(target.path, after);
339
+ const sentBefore = clientBefore ? "" : before;
340
+ const sentAfter = clientAfter ? "" : after;
341
+ if ((clientBefore || clientAfter) && sentBefore === "" && sentAfter === "") return undefined;
342
+
327
343
  const outcome = await call({
328
344
  endpoint,
329
345
  apiKey,
330
346
  timeoutMs,
331
347
  operation,
332
- artifact: { id: target.path, type: target.type, before, after },
348
+ artifact: { id: target.path, type: target.type, before: sentBefore, after: sentAfter },
333
349
  });
334
350
  return decide(outcome, { operation, path: target.path });
335
351
  };
@@ -2,7 +2,7 @@
2
2
  "id": "coderifts-contract-gate",
3
3
  "name": "CodeRifts contract gate",
4
4
  "description": "Ask CodeRifts before an agent writes a contract artifact.",
5
- "version": "0.3.2",
5
+ "version": "0.3.4",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderifts/agent-hooks",
3
- "version": "0.3.2",
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",