@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/claude-code/hook.mjs +2 -5
- package/claude-code/marketplace-entry.json +1 -1
- package/contract-write.mjs +134 -16
- package/entry-guard.mjs +19 -0
- package/gate.js +8 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
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
|
package/claude-code/hook.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
8
|
+
"version": "0.3.5",
|
|
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.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
|
-
*
|
|
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) {
|
|
@@ -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
|
-
/**
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
850
|
+
return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel));
|
|
746
851
|
} catch {
|
|
747
|
-
return
|
|
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
|
-
|
|
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)] });
|
package/entry-guard.mjs
ADDED
|
@@ -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;
|
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.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",
|