diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index e20aa10db5..06920f65b4 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -147,7 +147,9 @@ It exposes these read-only tools: - `gittensory_miner_list_plans` / `gittensory_miner_get_plan` (#5161) — read-only access to the persisted plan store (`planId`, plan DAG, status, `updatedAt`) via `listPlans` / `loadPlan`; `list_plans` takes an optional `status` filter, `get_plan` takes a `planId` and returns an explicit `{ planId, found: false }` for an unknown id. These read the store-backed AMS plan store — distinct from ORB's stateless `gittensory_plan_status` tool. -Further AMS-state-reading tools (status/doctor diagnostics, governor ledger) land as follow-up PRs on top of this server. +- `gittensory_miner_get_governor_decisions` (#5159) — read-only projection of the governor decision log (`id`, `ts`, `eventType`, `repoFullName`, `actionClass`, `decision`, `reason`), optionally filtered by `repoFullName`. The projection **excludes the sensitive `payload_json` column by construction** — `governor-ledger.js` reads it with an explicit named-column SELECT, never `SELECT *`. + +Further AMS-state-reading tools (status/doctor diagnostics) land as follow-up PRs on top of this server. ## Version check diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts index e35d35fa4d..1ec9069640 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -40,12 +40,20 @@ export interface MinerMcpServerOptions { listPlans(filter?: { status?: string | null }): unknown[]; close(): void; }; + /** + * Override the governor-ledger opener (defaults to the real on-disk ledger); injection seam for tests. Typed + * to the minimal read surface the decisions tool uses (the payload-excluding readGovernorDecisions). + */ + initGovernorLedger?: () => { + readGovernorDecisions(filter?: { repoFullName?: string | null }): unknown[]; + close(): void; + }; } /** * Build the miner MCP server with its tools registered (gittensory_miner_ping, * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed, - * gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan). `options` supplies - * test injection seams; production callers pass nothing. + * gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan, + * gittensory_miner_get_governor_decisions). `options` supplies test injection seams; production callers pass nothing. */ export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer; diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js index 7c9312fd8a..fe8eb39f65 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -14,6 +14,7 @@ import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; import { initRunStateStore } from "../lib/run-state.js"; import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js"; +import { initGovernorLedger } from "../lib/governor-ledger.js"; // MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp // harness (MCP SDK server + stdio transport). Tools: @@ -28,7 +29,9 @@ import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js"; // listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation). // - gittensory_miner_list_plans / gittensory_miner_get_plan (#5161): read-only access to the persisted // plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless gittensory_plan_status). -// Remaining AMS-state-reading tools (status/doctor, governor ledger, etc.) land as follow-ups. +// - gittensory_miner_get_governor_decisions (#5159): read-only governor decision-log projection via +// governor-ledger.js's readGovernorDecisions -- an explicit named-column read that excludes payload_json. +// Remaining AMS-state-reading tools (status/doctor, etc.) land as follow-ups. // Read the version from this package's own package.json (always shipped) rather than a hand-synced // literal, so a release bump never has a second place to forget -- same approach as the mcp harness. @@ -46,9 +49,9 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" } /** * Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`, - * `options.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, and `options.nowMs` are - * injection seams for tests (default to the real stores and the wall clock); the ping tool needs none. Each - * store-backed tool opens its store only when invoked and closes any store it opened. + * `options.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, `options.initGovernorLedger`, + * and `options.nowMs` are injection seams for tests (default to the real stores and the wall clock); the ping + * tool needs none. Each store-backed tool opens its store only when invoked and closes any store it opened. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -214,6 +217,31 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_get_governor_decisions", + { + description: + "Read-only projection of the governor decision log: id, ts, eventType, repoFullName, actionClass, " + + "decision, reason per row. This projection INTENTIONALLY EXCLUDES the internal/sensitive payload column " + + "(reputation / self-plagiarism / budget state) by construction -- governor-ledger.js reads it with an " + + "explicit named-column SELECT, never SELECT *. Optional repoFullName filter (the only filter the ledger " + + "supports natively). Read-only; never writes to the ledger.", + inputSchema: { + repoFullName: z.string().min(1).optional(), + }, + }, + async ({ repoFullName }) => { + const ownsLedger = options.initGovernorLedger === undefined; + const ledger = (options.initGovernorLedger ?? initGovernorLedger)(); + try { + const filter = {}; + if (repoFullName !== undefined) filter.repoFullName = repoFullName; + return { content: [{ type: "text", text: JSON.stringify(ledger.readGovernorDecisions(filter)) }] }; + } finally { + if (ownsLedger) ledger.close(); + } + }, + ); return server; } diff --git a/packages/gittensory-miner/lib/governor-ledger.d.ts b/packages/gittensory-miner/lib/governor-ledger.d.ts index 0a93b38fb4..98b2c7a9c6 100644 --- a/packages/gittensory-miner/lib/governor-ledger.d.ts +++ b/packages/gittensory-miner/lib/governor-ledger.d.ts @@ -22,10 +22,15 @@ export type ReadGovernorEventsFilter = { repoFullName?: string | null; }; +/** The public decision-log projection (#5159): every {@link GovernorLedgerEntry} field EXCEPT `payload`. */ +export type GovernorDecisionEntry = Omit; + export type GovernorLedger = { dbPath: string; appendGovernorEvent(event: AppendGovernorEventInput): GovernorLedgerEntry; readGovernorEvents(filter?: ReadGovernorEventsFilter): GovernorLedgerEntry[]; + /** Read-only decision-log projection; excludes `payload` by construction (explicit named-column SELECT). */ + readGovernorDecisions(filter?: ReadGovernorEventsFilter): GovernorDecisionEntry[]; close(): void; }; diff --git a/packages/gittensory-miner/lib/governor-ledger.js b/packages/gittensory-miner/lib/governor-ledger.js index 438efbf188..fef2bc847f 100644 --- a/packages/gittensory-miner/lib/governor-ledger.js +++ b/packages/gittensory-miner/lib/governor-ledger.js @@ -66,6 +66,21 @@ function rowToEntry(row) { }; } +// Decision-log projection (#5159): the public, MCP-exposed shape. Deliberately omits payload_json (which #5134 +// is expanding with reputation/self-plagiarism/budget state). Kept honest by an explicit named-column SELECT +// below — never SELECT * — so the sensitive column cannot leak even by accident. +function rowToDecision(row) { + return { + id: row.id, + ts: row.ts, + eventType: row.event_type, + repoFullName: row.repo_full_name, + actionClass: row.action_class, + decision: row.decision, + reason: row.reason, + }; +} + /** * Opens the append-only governor ledger, creating the table on first use. Rows are returned in ascending `id` * order (insertion order). (#2328) @@ -103,6 +118,15 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) { const readByRepoStatement = db.prepare( "SELECT * FROM governor_events WHERE repo_full_name = ? ORDER BY id ASC", ); + // Explicit named-column projection for the read-only decision log (#5159) — payload_json is intentionally + // NOT in this list, so widening it would be a deliberate edit that the redaction test guards against. + const decisionColumns = "id, ts, event_type, repo_full_name, action_class, decision, reason"; + const readDecisionsAllStatement = db.prepare( + `SELECT ${decisionColumns} FROM governor_events ORDER BY id ASC`, + ); + const readDecisionsByRepoStatement = db.prepare( + `SELECT ${decisionColumns} FROM governor_events WHERE repo_full_name = ? ORDER BY id ASC`, + ); return { dbPath: resolvedPath, @@ -128,6 +152,14 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) { : readByRepoStatement.all(repoFullName); return rows.map(rowToEntry); }, + readGovernorDecisions(filter = {}) { + const repoFullName = normalizeOptionalRepoFullName(filter.repoFullName); + const rows = + repoFullName === undefined + ? readDecisionsAllStatement.all() + : readDecisionsByRepoStatement.all(repoFullName); + return rows.map(rowToDecision); + }, close() { db.close(); }, diff --git a/test/unit/miner-mcp-governor-decisions.test.ts b/test/unit/miner-mcp-governor-decisions.test.ts new file mode 100644 index 0000000000..e3a6882b3d --- /dev/null +++ b/test/unit/miner-mcp-governor-decisions.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMinerMcpServer } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; + +// gittensory_miner_get_governor_decisions (#5159). Driven against a REAL temp governor ledger (not a fake) so the +// redaction assertion exercises the actual explicit-named-column SQL — it must fail if a future edit widens the +// SELECT to include payload_json. + +type Content = { content: Array<{ type: string; text?: string }> }; +type GovernorLedgerHandle = ReturnType; + +const roots: string[] = []; +function tempGovernorLedger(): GovernorLedgerHandle { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-mcp-governor-")); + roots.push(root); + return initGovernorLedger(join(root, "governor-ledger.sqlite3")); +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function toolText(result: Content): string { + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + throw new Error("expected a single text content block"); + } + return first.text; +} + +async function callGovernorDecisions( + ledger: GovernorLedgerHandle, + args: Record = {}, +): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-mcp-governor-test", version: "0.0.0" }); + await Promise.all([ + createMinerMcpServer({ initGovernorLedger: () => ledger }).connect(serverTransport), + client.connect(clientTransport), + ]); + const result = (await client.callTool({ + name: "gittensory_miner_get_governor_decisions", + arguments: args, + })) as Content; + return JSON.parse(toolText(result)); +} + +describe("gittensory_miner_get_governor_decisions (#5159)", () => { + it("projects the decision columns and NEVER leaks payload / reputation / budget (redaction by construction)", async () => { + const ledger = tempGovernorLedger(); + ledger.appendGovernorEvent({ + eventType: "denied", + repoFullName: "acme/api", + actionClass: "write", + decision: "block", + reason: "house rule violation", + // Sensitive state that #5134 is expanding into payload_json — must never surface through this read tool. + payload: { reputation: 0.2, self_plagiarism: true, budget: { remaining: 0 }, note: "secretish" }, + }); + + const decisions = (await callGovernorDecisions(ledger)) as Array>; + expect(decisions).toHaveLength(1); + expect(decisions[0]).toEqual({ + id: expect.any(Number), + ts: expect.any(String), + eventType: "denied", + repoFullName: "acme/api", + actionClass: "write", + decision: "block", + reason: "house rule violation", + }); + for (const forbidden of ["payload", "payload_json", "reputation", "self_plagiarism", "selfPlagiarism", "budget"]) { + expect(decisions[0]).not.toHaveProperty(forbidden); + } + // Belt-and-suspenders: the sensitive payload keys/values never appear anywhere in the serialized response. + // (Only tokens that cannot legitimately occur in a projected column — "budget" is skipped because it may + // appear in a decision `reason`; the not.toHaveProperty checks above already guard the payload key itself.) + const serialized = JSON.stringify(decisions); + for (const forbidden of ["reputation", "self_plagiarism", "secretish"]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it("filters by repoFullName", async () => { + const ledger = tempGovernorLedger(); + for (const repo of ["acme/api", "acme/web"]) { + ledger.appendGovernorEvent({ + eventType: "allowed", + repoFullName: repo, + actionClass: "analyze", + decision: "allow", + reason: "within budget", + }); + } + const decisions = (await callGovernorDecisions(ledger, { repoFullName: "acme/web" })) as Array<{ + repoFullName: string; + }>; + expect(decisions.map((decision) => decision.repoFullName)).toEqual(["acme/web"]); + }); + + it("returns an empty array when nothing matches", async () => { + const ledger = tempGovernorLedger(); + expect(await callGovernorDecisions(ledger, { repoFullName: "none/here" })).toEqual([]); + }); +}); diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 0823c2e1f8..c6c3feffe7 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -92,6 +92,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => { const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ "gittensory_miner_get_audit_feed", + "gittensory_miner_get_governor_decisions", "gittensory_miner_get_plan", "gittensory_miner_get_portfolio_dashboard", "gittensory_miner_get_run_state",