Skip to content
Closed
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
4 changes: 3 additions & 1 deletion packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
36 changes: 32 additions & 4 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/gittensory-miner/lib/governor-ledger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GovernorLedgerEntry, "payload">;

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;
};

Expand Down
32 changes: 32 additions & 0 deletions packages/gittensory-miner/lib/governor-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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();
},
Expand Down
109 changes: 109 additions & 0 deletions test/unit/miner-mcp-governor-decisions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof initGovernorLedger>;

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<string, unknown> = {},
): Promise<unknown> {
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<Record<string, unknown>>;
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([]);
});
});
1 change: 1 addition & 0 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down