@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hooks",
3
- "version": "0.3.3",
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,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.3",
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.3.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';
@@ -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
- * 'client_config' when the text is a JSON object with `mcpServers` or `servers` (an object) and no
69
- * `tools`; otherwise 'contract' — a `tools` key, any other shape, and text that does not parse
70
- * (fail-closed: what cannot be read as a client configuration is checked as a contract).
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 'contract';
81
+ return 'unparseable';
78
82
  }
79
- if (!isObject(doc) || 'tools' in doc) return 'contract';
80
- return isObject(doc.mcpServers) || isObject(doc.servers) ? 'client_config' : 'contract';
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
- if (typeof text !== 'string' || text === '') return false;
86
- const rel = normalizePath(path);
87
- return MCP_JSON_BY_CONTENT.test(rel) && !MCP_CLIENT_CONFIG.test(rel) && mcpJsonKind(text) === 'client_config';
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
- /** A shell write to a plain mcp.json that is a client configuration on disk is not a contract write. */
741
- async function clientConfigOnDisk(rel, io) {
742
- if (!MCP_JSON_BY_CONTENT.test(rel) || !io.readFile) return false;
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 isClientConfigContent(rel, await io.readFile(root ? `${root}/${rel}` : rel));
789
+ return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel));
746
790
  } catch {
747
- return false;
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
- if (type && await clientConfigOnDisk(inTree, io)) continue;
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;
@@ -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.3",
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.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",