Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/api-governance/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion plugins/api-governance/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# api-governance 1.2.8
# api-governance 1.2.9

<!-- GENERATED by coderifts-app scripts/generate-claude-package.js. Do not edit; edit the generator. -->

Expand Down Expand Up @@ -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.

Expand Down
150 changes: 134 additions & 16 deletions plugins/api-governance/hooks/contract-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ------------------------------------------------------------------------------

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}

Expand All @@ -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]);
}
Expand All @@ -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)] });
Expand Down
92 changes: 79 additions & 13 deletions plugins/api-governance/hooks/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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.
Expand All @@ -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([
Expand All @@ -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` }
Expand Down Expand Up @@ -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') {
Expand Down
Loading
Loading