From af3a2a8247fbba66c223848d58778cd691cec430 Mon Sep 17 00:00:00 2001 From: Peter Zsobrak <243386091+zsobpeter-code@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:19:28 +0200 Subject: [PATCH] api-governance 1.2.9: the mod reads ahead in the hook itself and answers only next(e) or a literal deny/ask; a plain mcp.json that is both a client configuration and a tool manifest, or that does not parse, is never sent (contract-write 1.3.2) (generated from coderifts-app) --- .claude-plugin/marketplace.json | 2 +- .../api-governance/.claude-plugin/plugin.json | 2 +- plugins/api-governance/README.md | 3 +- .../api-governance/hooks/contract-write.mjs | 150 ++++++++++++++++-- plugins/api-governance/hooks/register.js | 92 +++++++++-- plugins/api-governance/plugin.json | 2 +- 6 files changed, 218 insertions(+), 33 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3bebbc2..5577182 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "api-governance", "source": "./plugins/api-governance", "description": "Stops an agent's breaking edit to an OpenAPI, GraphQL, protobuf, AsyncAPI or MCP contract until a signed grant exists. The required check on the pull request stays the merge gate.", - "version": "1.2.8", + "version": "1.2.9", "author": { "name": "CodeRifts", "email": "peter@coderifts.com" diff --git a/plugins/api-governance/.claude-plugin/plugin.json b/plugins/api-governance/.claude-plugin/plugin.json index cc55ce2..9394860 100644 --- a/plugins/api-governance/.claude-plugin/plugin.json +++ b/plugins/api-governance/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "api-governance", - "version": "1.2.8", + "version": "1.2.9", "description": "Stops an agent's breaking edit to an OpenAPI, GraphQL, protobuf, AsyncAPI or MCP contract until a signed grant exists. The required check on the pull request stays the merge gate.", "author": { "name": "CodeRifts", diff --git a/plugins/api-governance/README.md b/plugins/api-governance/README.md index 38b9ad0..363ed4d 100644 --- a/plugins/api-governance/README.md +++ b/plugins/api-governance/README.md @@ -1,4 +1,4 @@ -# api-governance 1.2.8 +# api-governance 1.2.9 @@ -68,6 +68,7 @@ The plugin no longer reads the `CODERIFTS_API_KEY` or `CODERIFTS_API_BASE` envir command, the names of the files under the directories the command can write (not their contents). A Bash command's text is read on your machine and never sent. It sends no other file, no prompt and no transcript, and it contacts no other host. + An MCP client configuration, which can hold the credentials its servers start with, is never sent: `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `claude_desktop_config.json` and `(cline_)mcp_settings.json` are not read at all, and any other file named `mcp.json` is read on your machine only to classify it. A client configuration (`mcpServers` or `servers`, no `tools`) is passed on and never sent; a file that is both a client configuration and a tool manifest, or that does not parse, is never sent either — the mod asks. - **The MCP server** `coderifts` is `https://app.coderifts.com/mcp`: what you or Claude pass to its tools is sent there. - **The skill** sends nothing by itself. diff --git a/plugins/api-governance/hooks/contract-write.mjs b/plugins/api-governance/hooks/contract-write.mjs index fa53d79..d76c925 100644 --- a/plugins/api-governance/hooks/contract-write.mjs +++ b/plugins/api-governance/hooks/contract-write.mjs @@ -27,7 +27,7 @@ // written by scripts/generate-contract-write-copies.js, byte for byte (the CommonJS twin is a // mechanical transform), and its --check fails on any difference. Do not edit a copy. -export const CONTRACT_WRITE_VERSION = '1.3.0'; +export const CONTRACT_WRITE_VERSION = '1.3.2'; /** What a shell write to a named contract file gets, and what an unnamed one gets. */ export const SHELL_NAMED_DECISION = 'refuse'; @@ -65,26 +65,56 @@ export const MCP_JSON_BY_CONTENT = /(^|\/)mcp\.json$/i; const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); /** - * 'client_config' when the text is a JSON object with `mcpServers` or `servers` (an object) and no - * `tools`; otherwise 'contract' — a `tools` key, any other shape, and text that does not parse - * (fail-closed: what cannot be read as a client configuration is checked as a contract). + * The kind of a plain mcp.json's text, from its parsed TOP-LEVEL object (a leading BOM is ignored): + * 'client_config' `mcpServers` or `servers` (an object) and no `tools`: never sent, counts as no file + * 'mixed' `mcpServers` or `servers` (an object) AND `tools`: never sent, and not passed (P65d) + * 'unparseable' JSON.parse fails (comments, a trailing comma, …): never sent, and not passed (P65d) + * 'contract' anything else: a `tools` key, an object without a server list, a parsed non-object + * 1.3.1 (P65d, 2026-10-09): 1.3.0 checked `tools` first and called a parse failure a contract, so a server + * list with a `tools` key, and a client configuration with a comment, were sent as contracts. */ export function mcpJsonKind(text) { let doc; try { doc = JSON.parse(String(text ?? '').replace(/^\uFEFF/, '')); } catch { - return 'contract'; + return 'unparseable'; } - if (!isObject(doc) || 'tools' in doc) return 'contract'; - return isObject(doc.mcpServers) || isObject(doc.servers) ? 'client_config' : 'contract'; + if (!isObject(doc)) return 'contract'; + const servers = isObject(doc.mcpServers) || isObject(doc.servers); + if (servers) return 'tools' in doc ? 'mixed' : 'client_config'; + return 'contract'; +} + +/** The kind of one present side of a content-decided path; null for any other path or an empty side. */ +export function mcpJsonContentKind(path, text) { + if (typeof text !== 'string' || text === '') return null; + const rel = normalizePath(path); + if (!MCP_JSON_BY_CONTENT.test(rel) || MCP_CLIENT_CONFIG.test(rel)) return null; + return mcpJsonKind(text); } /** True when `path` is decided by content and `text` (one side, present) is a client configuration. */ export function isClientConfigContent(path, text) { - if (typeof text !== 'string' || text === '') return false; - const rel = normalizePath(path); - return MCP_JSON_BY_CONTENT.test(rel) && !MCP_CLIENT_CONFIG.test(rel) && mcpJsonKind(text) === 'client_config'; + return mcpJsonContentKind(path, text) === 'client_config'; +} + +/** 'mixed' or 'unparseable': never sent, and never passed silently — the hooks ask, the check is red. */ +export function isHeldContent(path, text) { + const k = mcpJsonContentKind(path, text); + return k === 'mixed' || k === 'unparseable'; +} + +/** Every side whose text no entry point sends: a client configuration, 'mixed', 'unparseable'. */ +export function isNeverSent(path, text) { + return isClientConfigContent(path, text) || isHeldContent(path, text); +} + +/** The one sentence every entry point says for a held side; null for any other kind. */ +export function heldWhy(path, kind) { + 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.`; + 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.`; + return null; } export function looksLikeContractPath(p) { @@ -682,6 +712,67 @@ export async function findUnder(listDir, root, match, limits = LISTING_LIMITS) { return { found: null, complete: true }; } +// ---- what a host reads ahead of the decision --------------------------------------------------- + +/* + * 1.3.2 (2026-10-09, the CodeRifts mod, plugin 1.2.9): a host that cannot let this module call its + * readers (the Claude directory reads every `return` in a hook's text as the hook's answer, and every + * mods API call reached through a helper is listed "via" it) reads AHEAD instead: readPlan names every + * file and every listing root decideToolCall can ask for on this call, and pendingListings names the + * directories the walk under those roots can still ask for, given the listings already read. The host + * reads those, then passes decideToolCall an io that only looks the answers up. Pure: no I/O here. + */ + +/** + * The reads decideToolCall can make for one call, from the same functions it decides with: + * files every path it can pass to io.readFile (verbatim) + * roots every listing root it can walk with io.listDir (findUnder's start, before normalizing) + * A superset: decideShell reads the named content-decided files of every target, and walks the + * scopes only when no target names a contract or a held file. + */ +export function readPlan(call, io = {}) { + const kind = toolKind(call && (call.tool ?? call.tool_name)); + const input = (call && (call.input ?? call.tool_input)) || {}; + const opts = { named: io.named || [], excluded: io.excluded || [] }; + if (kind === 'Bash') { + const files = []; + const roots = []; + const root = normalizePath(io.cwd || ''); + for (const { target } of shellWrites(String(field(input, 'command') ?? ''))) { + const inTree = scopeInTree(target, io); + if (inTree === null) continue; + const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null; + if (type) { + if (MCP_JSON_BY_CONTENT.test(inTree)) files.push(root ? `${root}/${inTree}` : inTree); + } else { + roots.push(listingRoot(inTree)); + } + } + return { files: [...new Set(files)], roots: [...new Set(roots)] }; + } + if (!kind) return { files: [], roots: [] }; + const filePath = filePathOf(input); + if (!filePath) return { files: [], roots: [] }; + return contractType(relativePath(io.cwd, filePath), opts) ? { files: [filePath], roots: [] } : { files: [], roots: [] }; +} + +/** + * The directories the walk under `roots` can still ask for, given `listings` ({ dir: entries | null }, + * the one-level answers already read, keyed as findUnder asks for them). The walk is findUnder itself, + * never stopping at a match, within the same limits — so the directories decideToolCall's walk asks + * for are a subset of what this keeps naming until it names none. + */ +export async function pendingListings(roots, listings = {}, limits = LISTING_LIMITS) { + const missing = new Set(); + const listDir = async (dir) => { + if (Object.prototype.hasOwnProperty.call(listings, dir)) return listings[dir]; + missing.add(dir); + return null; + }; + for (const r of roots || []) await findUnder(listDir, r, () => false, limits); + return [...missing]; +} + // ---- the decision ------------------------------------------------------------------------------ /** @@ -720,10 +811,16 @@ export async function decideToolCall(call, io = {}) { } catch (err) { return { action: 'refuse', reason: 'unreadable', rel, type, why: `${rel} is a contract file and could not be read (${String((err && err.message) || err)})` }; } + // 1.3.1 (P65d): a held side ('mixed', 'unparseable') — the file on disk, or the text the call writes — + // is not sent and not passed: the hooks ask, with the sentence. Nothing of it is in the answer. + const heldBefore = mcpJsonContentKind(rel, before); + if (heldBefore === 'mixed' || heldBefore === 'unparseable') return held(rel, type, heldBefore); const after = afterText(kind, input, before); if (after === null) { 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` }; } + const heldAfter = mcpJsonContentKind(rel, after); + if (heldAfter === 'mixed' || heldAfter === 'unparseable') return held(rel, type, heldAfter); // 1.3.0 (P65c): a plain mcp.json is decided by content, here, before anything is sent. A side that is // a client configuration counts as no file: both such → pass; one → the manifest added or removed. const clientBefore = isClientConfigContent(rel, before); @@ -737,14 +834,22 @@ export async function decideToolCall(call, io = {}) { return { action: 'gate', path: filePath, rel, type, before, after }; } -/** A shell write to a plain mcp.json that is a client configuration on disk is not a contract write. */ -async function clientConfigOnDisk(rel, io) { - if (!MCP_JSON_BY_CONTENT.test(rel) || !io.readFile) return false; +/** The answer for a held side: ask, with the sentence; no text of the file. */ +function held(rel, type, kind) { + return { action: 'ask', reason: 'mcp_json_held', rel, type, kind, scopes: [rel], why: heldWhy(rel, kind) }; +} + +/** + * The content kind of a plain mcp.json on disk, for a shell write that names it (null: not content- + * decided, missing, or unreadable — those stay the contract write they were). + */ +async function contentKindOnDisk(rel, io) { + if (!MCP_JSON_BY_CONTENT.test(rel) || !io.readFile) return null; const root = normalizePath(io.cwd || ''); try { - return isClientConfigContent(rel, await io.readFile(root ? `${root}/${rel}` : rel)); + return mcpJsonContentKind(rel, await io.readFile(root ? `${root}/${rel}` : rel)); } catch { - return false; + return null; } } @@ -770,12 +875,16 @@ async function mayHoldContract(scope, io, opts) { async function decideShell(command, io, opts) { const named = []; + const heldPaths = []; const scopes = new Map(); for (const { target, by } of shellWrites(command)) { const inTree = scopeInTree(target, io); if (inTree === null) continue; const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null; - if (type && await clientConfigOnDisk(inTree, io)) continue; + const onDisk = type ? await contentKindOnDisk(inTree, io) : null; + if (onDisk === 'client_config') continue; + // 1.3.1 (P65d): a held file on disk is not a contract write to refuse, and not a pass: ask. + if (onDisk === 'mixed' || onDisk === 'unparseable') { heldPaths.push({ rel: inTree, kind: onDisk }); continue; } if (type) named.push({ path: target, type, by }); else scopes.set(inTree, [...(scopes.get(inTree) || []), by]); } @@ -787,6 +896,15 @@ async function decideShell(command, io, opts) { 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`, }; } + if (heldPaths.length) { + return { + action: 'ask', + reason: 'mcp_json_held', + kind: heldPaths[0].kind, + scopes: heldPaths.map((h) => h.rel), + why: heldPaths.map((h) => heldWhy(h.rel, h.kind)).join(' '), + }; + } const reached = []; for (const [scope, bys] of scopes) { if (await mayHoldContract(scope, io, opts)) reached.push({ scope, by: [...new Set(bys)] }); diff --git a/plugins/api-governance/hooks/register.js b/plugins/api-governance/hooks/register.js index 7e79948..4956236 100644 --- a/plugins/api-governance/hooks/register.js +++ b/plugins/api-governance/hooks/register.js @@ -35,7 +35,7 @@ // // Generated into the plugin by scripts/generate-claude-package.js. Edit this file, not the copy. -import { afterText, decideToolCall } from './contract-write.mjs'; +import { afterText, decideToolCall, pendingListings, readPlan } from './contract-write.mjs'; const DEADLINE_MS = 8000; const TIMED_OUT = Object.freeze({ timedOut: true }); @@ -63,6 +63,35 @@ function emptyTurn() { return { refused: 0, passed: 0, last: null, lastRefused: null }; } +// 1.2.9 (2026-10-08/09, the directory's MOD_PERMISSION_ANSWER_UNREAD at register.js:88 of 1.2.7): the +// directory reads every return in a hook's text as the hook's answer, nested functions included, and +// 1.2.7's io readers returned calls (`return listingOf(await $.fs.list(...))`). Every $.fs call is made in +// the hook itself, AHEAD of the decision, from contract-write's readPlan / pendingListings, each awaited +// into a variable; contract-write then reads only these answers. ioFrom holds no mods API, so no call is +// reached "via" a helper (claude plugin validate). Something not read ahead is unreadable to contract-write +// (fail-closed). test/claude-mod-preread.test.js and test/claude-mod-directory-policy.test.js hold both. +function ioFrom(cwd, named, files, listings) { + return { + cwd, + named, + readFile: async (p) => { + const f = Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null; + if (!f) throw new Error(`${p} was not read ahead`); + if (f.error !== undefined) throw new Error(f.error); + return f.missing ? null : f.text; + }, + listDir: async (dir) => { + if (!Object.prototype.hasOwnProperty.call(listings, dir)) throw new Error(`${dir} was not listed ahead`); + return listings[dir]; + }, + }; +} + +/** What the deadline resolves to (outside the hook for the same reason). */ +function timedOut() { + return TIMED_OUT; +} + export function register(on, options) { // The plugin's `api_key` option (userConfig, sensitive): stored by Claude Code in secure storage and // handed here. No key → analyze, which authorizes nothing. @@ -79,26 +108,50 @@ export function register(on, options) { } catch { yml = null; } - const io = { - cwd, - named: parseSchemaList(typeof yml === 'string' ? yml : null), - readFile: async (p) => ((await $.fs.exists(p)) ? $.fs.read(p) : null), - listDir: async (dir) => { + const named = parseSchemaList(typeof yml === 'string' ? yml : null); + const call = { tool: e.tool, input: e.input || {} }; + // Read ahead (1.2.9): every file and every listing contract-write can ask for on this call. + const plan = readPlan(call, { cwd, named }); + let files = {}; + for (const p of plan.files) { + let entry; + try { + const there = await $.fs.exists(p); + entry = there ? { text: await $.fs.read(p) } : { missing: true }; + } catch (err) { + entry = { error: String((err && err.message) || err) }; + } + files = { ...files, [p]: entry }; + } + let listings = {}; + let pending = await pendingListings(plan.roots, listings); + while (pending.length) { + for (const dir of pending) { + let entries; try { - return listingOf(await $.fs.list(dir === '.' ? cwd : joinPath(cwd, dir))); + entries = listingOf(await $.fs.list(dir === '.' ? cwd : joinPath(cwd, dir))); } catch { - return null; + entries = null; } - }, - }; - const d = await decideToolCall({ tool: e.tool, input: e.input || {} }, io); + listings = { ...listings, [dir]: entries }; + } + pending = await pendingListings(plan.roots, listings); + } + const d = await decideToolCall(call, ioFrom(cwd, named, files, listings)); if (d.action === 'pass') return next(e); let outcome; - if (e.tool === 'Bash') { + if (d.reason === 'mcp_json_held') { + // 1.2.9 (P65d): a plain mcp.json that is both a client configuration and a tool manifest, or that + // does not parse — never sent; the user is asked, with contract-write's one sentence. + outcome = heldOutcome(d); + } else if (e.tool === 'Bash') { outcome = shellOutcome(d); } else if (d.action === 'refuse') { outcome = localOutcome(d); + } else if (d.action !== 'gate') { + // Only a 'gate' decision is sent; anything else contract-write might answer asks. + outcome = undecidedOutcome(d); } else { const artifact = { id: 'api', type: d.type, before: d.before, after: d.after }; const answer = await Promise.race([ @@ -111,7 +164,7 @@ export function register(on, options) { ? { preflight_mode: 'authorize', artifacts: [artifact], context: { operation: 'merge', environment: 'staging' } } : { preflight_mode: 'analyze', artifacts: [artifact] }), }), - $.clock.sleep(DEADLINE_MS).then(() => TIMED_OUT), + $.clock.sleep(DEADLINE_MS).then(timedOut), ]); const verdict = answer === TIMED_OUT ? { branch: 'unavailable', label: 'GOVERNANCE_UNAVAILABLE', why: `CodeRifts did not answer within ${DEADLINE_MS / 1000} s` } @@ -276,6 +329,19 @@ function localOutcome(d) { return { branch: 'deny', band, line: `CodeRifts refused · ${d.rel} · ${d.why}`, reason: `CodeRifts refused this edit of ${d.rel}: ${d.why}. Next step: read the file again and redo the edit. ${MERGE_GATE}` }; } +/** P65d: a held plain mcp.json ('mixed' / 'unparseable'), from a file tool or a shell write — ask, nothing sent. */ +function heldOutcome(d) { + const rel = d.rel || (d.scopes || []).join(', ') || 'mcp.json'; + return { branch: 'ask', band: { label: 'not checked', digest: null, rel, refused: true, decisionId: null }, line: `CodeRifts asks · ${rel} · ${d.why}`, reason: `CodeRifts: ${d.why} ${MERGE_GATE}` }; +} + +/** A decision contract-write gives that this mod does not know: ask, nothing sent. */ +function undecidedOutcome(d) { + const rel = d.rel || 'a contract file'; + const why = `contract-write answered ${d.action || 'nothing'}`; + return { branch: 'ask', band: { label: 'GOVERNANCE_UNAVAILABLE', digest: null, rel, refused: true, decisionId: null }, line: `CodeRifts could not decide · ${rel} · ${why}`, reason: `CodeRifts could not check this edit of ${rel}. ${governanceUnavailable(why)} ${MERGE_GATE}` }; +} + /** contract-write's answers for a Bash command: a named contract write is refused, an unnamed reach asks. */ function shellOutcome(d) { if (d.action === 'ask') { diff --git a/plugins/api-governance/plugin.json b/plugins/api-governance/plugin.json index 0bd99c2..34df836 100644 --- a/plugins/api-governance/plugin.json +++ b/plugins/api-governance/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "api-governance", - "version": "1.2.8", + "version": "1.2.9", "description": "Stops an agent's breaking edit to an OpenAPI, GraphQL, protobuf, AsyncAPI or MCP contract until a signed grant exists. The required check on the pull request stays the merge gate.", "author": { "name": "CodeRifts",