@coderifts/agent-hooks 0.3.3 → 0.3.5

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.5",
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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5 — 2026-10-09
4
+ ### Fixed
5
+ - **The Claude Code hook runs when invoked through a symlinked path.** The entry check resolves the invoked path and this file. The same check in `scripts/sync-version.mjs` does too. If the check cannot tell, the entry runs.
6
+ - **contract-write 1.3.2 (no decision changes).** The generated copy adds `readPlan` and `pendingListings`.
7
+
8
+ ## 0.3.4 — 2026-10-09
9
+ ### Fixed
10
+ - **A plain `mcp.json` that is both an MCP client configuration and a tool manifest, or that does not parse, is not
11
+ read or sent and is not passed (contract-write 1.3.1).** On the Claude Code file tools and on the OpenClaw tool
12
+ shapes the gate asks, with one sentence: split the server list and the tool manifest into separate files, or make
13
+ the file valid JSON (no comments) to have it checked.
14
+
3
15
  ## 0.3.3 — 2026-10-08
4
16
  ### Fixed
5
17
  - **A plain `mcp.json` is decided by its content (contract-write 1.3.0, P65c).** 0.3.2 skipped every `mcp.json` by
@@ -16,9 +16,8 @@
16
16
  * directory holding a contract without naming it (find -exec, xargs, a glob, a tree-wide git
17
17
  * restore, an interpreter, a pipe into sh) asks.
18
18
  */
19
- import { resolve } from "node:path";
20
- import { pathToFileURL } from "node:url";
21
19
  import { afterText, decideToolCall } from "../contract-write.mjs";
20
+ import { invokedDirectly } from "../entry-guard.mjs";
22
21
  import { createGate, DOES_NOT_PROVE, governanceUnavailable, hostIo } from "../gate.js";
23
22
 
24
23
  /** The file after an Edit (`params` is the edit) or a MultiEdit (`params.edits`, in order): contract-write's afterText. */
@@ -166,8 +165,6 @@ async function main() {
166
165
  process.exit(out.exitCode);
167
166
  }
168
167
 
169
- const invokedDirectly =
170
- Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
171
- if (invokedDirectly) {
168
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
172
169
  main();
173
170
  }
@@ -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.5",
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.2';
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) {
@@ -682,6 +712,67 @@ export async function findUnder(listDir, root, match, limits = LISTING_LIMITS) {
682
712
  return { found: null, complete: true };
683
713
  }
684
714
 
715
+ // ---- what a host reads ahead of the decision ---------------------------------------------------
716
+
717
+ /*
718
+ * 1.3.2 (2026-10-09, the CodeRifts mod, plugin 1.2.9): a host that cannot let this module call its
719
+ * readers (the Claude directory reads every `return` in a hook's text as the hook's answer, and every
720
+ * mods API call reached through a helper is listed "via" it) reads AHEAD instead: readPlan names every
721
+ * file and every listing root decideToolCall can ask for on this call, and pendingListings names the
722
+ * directories the walk under those roots can still ask for, given the listings already read. The host
723
+ * reads those, then passes decideToolCall an io that only looks the answers up. Pure: no I/O here.
724
+ */
725
+
726
+ /**
727
+ * The reads decideToolCall can make for one call, from the same functions it decides with:
728
+ * files every path it can pass to io.readFile (verbatim)
729
+ * roots every listing root it can walk with io.listDir (findUnder's start, before normalizing)
730
+ * A superset: decideShell reads the named content-decided files of every target, and walks the
731
+ * scopes only when no target names a contract or a held file.
732
+ */
733
+ export function readPlan(call, io = {}) {
734
+ const kind = toolKind(call && (call.tool ?? call.tool_name));
735
+ const input = (call && (call.input ?? call.tool_input)) || {};
736
+ const opts = { named: io.named || [], excluded: io.excluded || [] };
737
+ if (kind === 'Bash') {
738
+ const files = [];
739
+ const roots = [];
740
+ const root = normalizePath(io.cwd || '');
741
+ for (const { target } of shellWrites(String(field(input, 'command') ?? ''))) {
742
+ const inTree = scopeInTree(target, io);
743
+ if (inTree === null) continue;
744
+ const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
745
+ if (type) {
746
+ if (MCP_JSON_BY_CONTENT.test(inTree)) files.push(root ? `${root}/${inTree}` : inTree);
747
+ } else {
748
+ roots.push(listingRoot(inTree));
749
+ }
750
+ }
751
+ return { files: [...new Set(files)], roots: [...new Set(roots)] };
752
+ }
753
+ if (!kind) return { files: [], roots: [] };
754
+ const filePath = filePathOf(input);
755
+ if (!filePath) return { files: [], roots: [] };
756
+ return contractType(relativePath(io.cwd, filePath), opts) ? { files: [filePath], roots: [] } : { files: [], roots: [] };
757
+ }
758
+
759
+ /**
760
+ * The directories the walk under `roots` can still ask for, given `listings` ({ dir: entries | null },
761
+ * the one-level answers already read, keyed as findUnder asks for them). The walk is findUnder itself,
762
+ * never stopping at a match, within the same limits — so the directories decideToolCall's walk asks
763
+ * for are a subset of what this keeps naming until it names none.
764
+ */
765
+ export async function pendingListings(roots, listings = {}, limits = LISTING_LIMITS) {
766
+ const missing = new Set();
767
+ const listDir = async (dir) => {
768
+ if (Object.prototype.hasOwnProperty.call(listings, dir)) return listings[dir];
769
+ missing.add(dir);
770
+ return null;
771
+ };
772
+ for (const r of roots || []) await findUnder(listDir, r, () => false, limits);
773
+ return [...missing];
774
+ }
775
+
685
776
  // ---- the decision ------------------------------------------------------------------------------
686
777
 
687
778
  /**
@@ -720,10 +811,16 @@ export async function decideToolCall(call, io = {}) {
720
811
  } catch (err) {
721
812
  return { action: 'refuse', reason: 'unreadable', rel, type, why: `${rel} is a contract file and could not be read (${String((err && err.message) || err)})` };
722
813
  }
814
+ // 1.3.1 (P65d): a held side ('mixed', 'unparseable') — the file on disk, or the text the call writes —
815
+ // is not sent and not passed: the hooks ask, with the sentence. Nothing of it is in the answer.
816
+ const heldBefore = mcpJsonContentKind(rel, before);
817
+ if (heldBefore === 'mixed' || heldBefore === 'unparseable') return held(rel, type, heldBefore);
723
818
  const after = afterText(kind, input, before);
724
819
  if (after === null) {
725
820
  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
821
  }
822
+ const heldAfter = mcpJsonContentKind(rel, after);
823
+ if (heldAfter === 'mixed' || heldAfter === 'unparseable') return held(rel, type, heldAfter);
727
824
  // 1.3.0 (P65c): a plain mcp.json is decided by content, here, before anything is sent. A side that is
728
825
  // a client configuration counts as no file: both such → pass; one → the manifest added or removed.
729
826
  const clientBefore = isClientConfigContent(rel, before);
@@ -737,14 +834,22 @@ export async function decideToolCall(call, io = {}) {
737
834
  return { action: 'gate', path: filePath, rel, type, before, after };
738
835
  }
739
836
 
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;
837
+ /** The answer for a held side: ask, with the sentence; no text of the file. */
838
+ function held(rel, type, kind) {
839
+ return { action: 'ask', reason: 'mcp_json_held', rel, type, kind, scopes: [rel], why: heldWhy(rel, kind) };
840
+ }
841
+
842
+ /**
843
+ * The content kind of a plain mcp.json on disk, for a shell write that names it (null: not content-
844
+ * decided, missing, or unreadable — those stay the contract write they were).
845
+ */
846
+ async function contentKindOnDisk(rel, io) {
847
+ if (!MCP_JSON_BY_CONTENT.test(rel) || !io.readFile) return null;
743
848
  const root = normalizePath(io.cwd || '');
744
849
  try {
745
- return isClientConfigContent(rel, await io.readFile(root ? `${root}/${rel}` : rel));
850
+ return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel));
746
851
  } catch {
747
- return false;
852
+ return null;
748
853
  }
749
854
  }
750
855
 
@@ -770,12 +875,16 @@ async function mayHoldContract(scope, io, opts) {
770
875
 
771
876
  async function decideShell(command, io, opts) {
772
877
  const named = [];
878
+ const heldPaths = [];
773
879
  const scopes = new Map();
774
880
  for (const { target, by } of shellWrites(command)) {
775
881
  const inTree = scopeInTree(target, io);
776
882
  if (inTree === null) continue;
777
883
  const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
778
- if (type && await clientConfigOnDisk(inTree, io)) continue;
884
+ const onDisk = type ? await contentKindOnDisk(inTree, io) : null;
885
+ if (onDisk === 'client_config') continue;
886
+ // 1.3.1 (P65d): a held file on disk is not a contract write to refuse, and not a pass: ask.
887
+ if (onDisk === 'mixed' || onDisk === 'unparseable') { heldPaths.push({ rel: inTree, kind: onDisk }); continue; }
779
888
  if (type) named.push({ path: target, type, by });
780
889
  else scopes.set(inTree, [...(scopes.get(inTree) || []), by]);
781
890
  }
@@ -787,6 +896,15 @@ async function decideShell(command, io, opts) {
787
896
  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
897
  };
789
898
  }
899
+ if (heldPaths.length) {
900
+ return {
901
+ action: 'ask',
902
+ reason: 'mcp_json_held',
903
+ kind: heldPaths[0].kind,
904
+ scopes: heldPaths.map((h) => h.rel),
905
+ why: heldPaths.map((h) => heldWhy(h.rel, h.kind)).join(' '),
906
+ };
907
+ }
790
908
  const reached = [];
791
909
  for (const [scope, bys] of scopes) {
792
910
  if (await mayHoldContract(scope, io, opts)) reached.push({ scope, by: [...new Set(bys)] });
@@ -0,0 +1,19 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Whether `moduleUrl` is the process entry.
6
+ *
7
+ * Node sets `import.meta.url` to the real path of the module and leaves
8
+ * `process.argv[1]` as the path it was invoked with. Both sides are realpath'd.
9
+ * No argv, no module URL, or a realpath that throws → true (the entry runs).
10
+ * A resolved path that is a different file → false (this module was imported).
11
+ */
12
+ export function invokedDirectly(argv1, moduleUrl) {
13
+ if (!argv1 || !moduleUrl) return true;
14
+ try {
15
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
16
+ } catch {
17
+ return true;
18
+ }
19
+ }
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.5",
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.5",
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",
@@ -15,6 +15,7 @@
15
15
  "main": "./index.js",
16
16
  "files": [
17
17
  "index.js",
18
+ "entry-guard.mjs",
18
19
  "gate.js",
19
20
  "contract-write.mjs",
20
21
  "openclaw.plugin.json",