@coderifts/agent-hooks 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
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.3 — 2026-10-08
4
+ ### Fixed
5
+ - **A plain `mcp.json` is decided by its content (contract-write 1.3.0, P65c).** 0.3.2 skipped every `mcp.json` by
6
+ name, which also skipped a server's tool manifest of that name. By name only `.mcp.json`, `.cursor/mcp.json`,
7
+ `.vscode/mcp.json`, `claude_desktop_config.json` and `(cline_)mcp_settings.json` stay skipped (never read). Any
8
+ other `mcp.json` is read on disk: `mcpServers` / `servers` and no `tools` → a client configuration, nothing sent;
9
+ `tools`, any other shape, or unparseable → checked as a contract. A manifest rewritten into a client configuration
10
+ is checked as the manifest removed, without the client side. The same on the OpenClaw tool shapes (`write_file` …).
11
+
12
+ ## 0.3.2 — 2026-10-06
13
+ ### Fixed
14
+ - **An MCP client configuration is never treated as a contract (contract-write 1.2.0, P65).** `.mcp.json`, `mcp.json`
15
+ (also under `.cursor/` and `.vscode/`), `claude_desktop_config.json` and `(cline_)mcp_settings.json`: an edit of one
16
+ sent its whole text, the servers' `env` credentials included, to CodeRifts preflight. They are decided by name and
17
+ never read; MCP tool manifests are unchanged.
18
+
3
19
  ## 0.3.1 — 2026-10-04
4
20
 
5
21
  ### Changed
@@ -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.1",
8
+ "version": "0.3.3",
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.1.0';
30
+ export const CONTRACT_WRITE_VERSION = '1.3.0';
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';
@@ -41,9 +41,56 @@ export const LISTING_LIMITS = Object.freeze({ depth: 8, entries: 5000 });
41
41
  /** The @coderifts/contract-path list (index.cjs), word for word; test/contract-write.test.js holds them equal. */
42
42
  export const CONTRACT_EXT = /\.(ya?ml|json|graphql|gql|proto)$/i;
43
43
 
44
+ /*
45
+ * 1.2.0 (2026-10-06, the Claude directory's hold MCP_FORWARDS_CREDENTIAL_ENV): an MCP CLIENT
46
+ * configuration file — the list of servers a client starts, with their `env` credentials — is not a
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. MCP tool manifests are unchanged.
49
+ * P65 (2026-10-06): the required check uses this same pattern — index.cjs requires it from here, and its
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.
59
+ */
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
+ * '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).
71
+ */
72
+ export function mcpJsonKind(text) {
73
+ let doc;
74
+ try {
75
+ doc = JSON.parse(String(text ?? '').replace(/^\uFEFF/, ''));
76
+ } catch {
77
+ return 'contract';
78
+ }
79
+ if (!isObject(doc) || 'tools' in doc) return 'contract';
80
+ return isObject(doc.mcpServers) || isObject(doc.servers) ? 'client_config' : 'contract';
81
+ }
82
+
83
+ /** True when `path` is decided by content and `text` (one side, present) is a client configuration. */
84
+ 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';
88
+ }
89
+
44
90
  export function looksLikeContractPath(p) {
45
91
  const s = String(p || '').toLowerCase();
46
92
  if (s.includes('node_modules/') || s.includes('vendor/')) return false;
93
+ if (MCP_CLIENT_CONFIG.test(s)) return false;
47
94
  return CONTRACT_EXT.test(s) && (s.includes('openapi') || s.includes('swagger') || s.includes('asyncapi')
48
95
  || s.endsWith('.graphql') || s.endsWith('.gql') || s.endsWith('.proto') || s.includes('mcp'));
49
96
  }
@@ -68,6 +115,7 @@ const HOOK_EXTRAS = Object.freeze([
68
115
  [/(^|\/)tools\.(wire\.v1\.)?json$/i, 'mcp_manifest'],
69
116
  ]);
70
117
 
118
+
71
119
  /** `a/./b/../c` → `a/c`; backslashes become slashes; a leading `./` goes. */
72
120
  export function normalizePath(p) {
73
121
  const s = String(p || '').replace(/\\/g, '/');
@@ -114,6 +162,7 @@ export function contractType(p, { named = [], excluded = [] } = {}) {
114
162
  const rel = normalizePath(p);
115
163
  if (!rel) return null;
116
164
  if (excluded.some((x) => matchesPattern(rel, x))) return null;
165
+ if (MCP_CLIENT_CONFIG.test(rel)) return null;
117
166
  for (const [re, type] of HOOK_EXTRAS) if (re.test(rel) && !/(^|\/)(node_modules|vendor)\//i.test(rel)) return type;
118
167
  if (looksLikeContractPath(rel)) return typeForPath(rel);
119
168
  if (named.some((x) => matchesPattern(rel, x))) return typeForPath(rel);
@@ -675,9 +724,30 @@ export async function decideToolCall(call, io = {}) {
675
724
  if (after === null) {
676
725
  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` };
677
726
  }
727
+ // 1.3.0 (P65c): a plain mcp.json is decided by content, here, before anything is sent. A side that is
728
+ // a client configuration counts as no file: both such → pass; one → the manifest added or removed.
729
+ const clientBefore = isClientConfigContent(rel, before);
730
+ const clientAfter = isClientConfigContent(rel, after);
731
+ if (clientBefore || clientAfter) {
732
+ const b = clientBefore ? '' : before;
733
+ const a = clientAfter ? '' : after;
734
+ if (b === '' && a === '') return { action: 'pass', reason: 'mcp_client_config', rel };
735
+ return { action: 'gate', path: filePath, rel, type, before: b, after: a };
736
+ }
678
737
  return { action: 'gate', path: filePath, rel, type, before, after };
679
738
  }
680
739
 
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;
743
+ const root = normalizePath(io.cwd || '');
744
+ try {
745
+ return isClientConfigContent(rel, await io.readFile(root ? `${root}/${rel}` : rel));
746
+ } catch {
747
+ return false;
748
+ }
749
+ }
750
+
681
751
  /** Is there a contract file under `scope`? Unknown (no reader, a limit hit) counts as yes. */
682
752
  async function mayHoldContract(scope, io, opts) {
683
753
  const glob = /[*?]/.test(scope) ? globToRegExp(normalizePath(scope)) : null;
@@ -705,6 +775,7 @@ async function decideShell(command, io, opts) {
705
775
  const inTree = scopeInTree(target, io);
706
776
  if (inTree === null) continue;
707
777
  const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
778
+ if (type && await clientConfigOnDisk(inTree, io)) continue;
708
779
  if (type) named.push({ path: target, type, by });
709
780
  else scopes.set(inTree, [...(scopes.get(inTree) || []), by]);
710
781
  }
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, isClientConfigContent, 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({
@@ -324,12 +324,21 @@ export function createGate(config = {}, deps = {}) {
324
324
  );
325
325
  }
326
326
 
327
+ // 0.3.3 (P65c): a plain mcp.json is decided by its content (contract-write's function), as on the
328
+ // Claude Code path. A side that is an MCP client configuration counts as no file and is never sent;
329
+ // both such sides → this gate never claimed the call.
330
+ const clientBefore = isClientConfigContent(target.path, before);
331
+ const clientAfter = isClientConfigContent(target.path, after);
332
+ const sentBefore = clientBefore ? "" : before;
333
+ const sentAfter = clientAfter ? "" : after;
334
+ if ((clientBefore || clientAfter) && sentBefore === "" && sentAfter === "") return undefined;
335
+
327
336
  const outcome = await call({
328
337
  endpoint,
329
338
  apiKey,
330
339
  timeoutMs,
331
340
  operation,
332
- artifact: { id: target.path, type: target.type, before, after },
341
+ artifact: { id: target.path, type: target.type, before: sentBefore, after: sentAfter },
333
342
  });
334
343
  return decide(outcome, { operation, path: target.path });
335
344
  };
@@ -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.1",
5
+ "version": "0.3.3",
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.1",
3
+ "version": "0.3.3",
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",