Skip to content

Commit cffe5aa

Browse files
feat(miner): expose governor decisions via a read-only, payload-redacted MCP tool (#5413)
Add gittensory_miner_get_governor_decisions to the gittensory-miner MCP server (scaffold #5153): a read-only projection of the governor decision log (id, ts, eventType, repoFullName, actionClass, decision, reason), optionally filtered by repoFullName. Excludes the sensitive payload_json column (reputation/self-plagiarism/budget state that #5134 is expanding) BY CONSTRUCTION: a new readGovernorDecisions() reader in governor-ledger.js uses an explicit named-column SELECT, never SELECT *. The write path and existing readGovernorEvents are untouched. A dedicated test drives a real temp ledger seeded with a payload and asserts the projection never leaks payload/reputation/self_plagiarism/budget -- so it fails if a future edit widens the SELECT. Closes #5159 Co-authored-by: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com>
1 parent 0d78f24 commit cffe5aa

7 files changed

Lines changed: 192 additions & 7 deletions

File tree

packages/gittensory-miner/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ It exposes these read-only tools:
147147

148148
- `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.
149149

150-
Further AMS-state-reading tools (status/doctor diagnostics, governor ledger) land as follow-up PRs on top of this server.
150+
- `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 *`.
151+
152+
Further AMS-state-reading tools (status/doctor diagnostics) land as follow-up PRs on top of this server.
151153

152154
## Version check
153155

packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,20 @@ export interface MinerMcpServerOptions {
4040
listPlans(filter?: { status?: string | null }): unknown[];
4141
close(): void;
4242
};
43+
/**
44+
* Override the governor-ledger opener (defaults to the real on-disk ledger); injection seam for tests. Typed
45+
* to the minimal read surface the decisions tool uses (the payload-excluding readGovernorDecisions).
46+
*/
47+
initGovernorLedger?: () => {
48+
readGovernorDecisions(filter?: { repoFullName?: string | null }): unknown[];
49+
close(): void;
50+
};
4351
}
4452

4553
/**
4654
* Build the miner MCP server with its tools registered (gittensory_miner_ping,
4755
* gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed,
48-
* gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan). `options` supplies
49-
* test injection seams; production callers pass nothing.
56+
* gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan,
57+
* gittensory_miner_get_governor_decisions). `options` supplies test injection seams; production callers pass nothing.
5058
*/
5159
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;

packages/gittensory-miner/bin/gittensory-miner-mcp.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
1414
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
1515
import { initRunStateStore } from "../lib/run-state.js";
1616
import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js";
17+
import { initGovernorLedger } from "../lib/governor-ledger.js";
1718

1819
// MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp
1920
// harness (MCP SDK server + stdio transport). Tools:
@@ -28,7 +29,9 @@ import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js";
2829
// listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation).
2930
// - gittensory_miner_list_plans / gittensory_miner_get_plan (#5161): read-only access to the persisted
3031
// plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless gittensory_plan_status).
31-
// Remaining AMS-state-reading tools (status/doctor, governor ledger, etc.) land as follow-ups.
32+
// - gittensory_miner_get_governor_decisions (#5159): read-only governor decision-log projection via
33+
// governor-ledger.js's readGovernorDecisions -- an explicit named-column read that excludes payload_json.
34+
// Remaining AMS-state-reading tools (status/doctor, etc.) land as follow-ups.
3235

3336
// Read the version from this package's own package.json (always shipped) rather than a hand-synced
3437
// 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" }
4649

4750
/**
4851
* Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`,
49-
* `options.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, and `options.nowMs` are
50-
* injection seams for tests (default to the real stores and the wall clock); the ping tool needs none. Each
51-
* store-backed tool opens its store only when invoked and closes any store it opened.
52+
* `options.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, `options.initGovernorLedger`,
53+
* and `options.nowMs` are injection seams for tests (default to the real stores and the wall clock); the ping
54+
* tool needs none. Each store-backed tool opens its store only when invoked and closes any store it opened.
5255
*/
5356
export function createMinerMcpServer(options = {}) {
5457
const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version });
@@ -214,6 +217,31 @@ export function createMinerMcpServer(options = {}) {
214217
}
215218
},
216219
);
220+
server.registerTool(
221+
"gittensory_miner_get_governor_decisions",
222+
{
223+
description:
224+
"Read-only projection of the governor decision log: id, ts, eventType, repoFullName, actionClass, " +
225+
"decision, reason per row. This projection INTENTIONALLY EXCLUDES the internal/sensitive payload column " +
226+
"(reputation / self-plagiarism / budget state) by construction -- governor-ledger.js reads it with an " +
227+
"explicit named-column SELECT, never SELECT *. Optional repoFullName filter (the only filter the ledger " +
228+
"supports natively). Read-only; never writes to the ledger.",
229+
inputSchema: {
230+
repoFullName: z.string().min(1).optional(),
231+
},
232+
},
233+
async ({ repoFullName }) => {
234+
const ownsLedger = options.initGovernorLedger === undefined;
235+
const ledger = (options.initGovernorLedger ?? initGovernorLedger)();
236+
try {
237+
const filter = {};
238+
if (repoFullName !== undefined) filter.repoFullName = repoFullName;
239+
return { content: [{ type: "text", text: JSON.stringify(ledger.readGovernorDecisions(filter)) }] };
240+
} finally {
241+
if (ownsLedger) ledger.close();
242+
}
243+
},
244+
);
217245
return server;
218246
}
219247

packages/gittensory-miner/lib/governor-ledger.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ export type ReadGovernorEventsFilter = {
2222
repoFullName?: string | null;
2323
};
2424

25+
/** The public decision-log projection (#5159): every {@link GovernorLedgerEntry} field EXCEPT `payload`. */
26+
export type GovernorDecisionEntry = Omit<GovernorLedgerEntry, "payload">;
27+
2528
export type GovernorLedger = {
2629
dbPath: string;
2730
appendGovernorEvent(event: AppendGovernorEventInput): GovernorLedgerEntry;
2831
readGovernorEvents(filter?: ReadGovernorEventsFilter): GovernorLedgerEntry[];
32+
/** Read-only decision-log projection; excludes `payload` by construction (explicit named-column SELECT). */
33+
readGovernorDecisions(filter?: ReadGovernorEventsFilter): GovernorDecisionEntry[];
2934
close(): void;
3035
};
3136

packages/gittensory-miner/lib/governor-ledger.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ function rowToEntry(row) {
6666
};
6767
}
6868

69+
// Decision-log projection (#5159): the public, MCP-exposed shape. Deliberately omits payload_json (which #5134
70+
// is expanding with reputation/self-plagiarism/budget state). Kept honest by an explicit named-column SELECT
71+
// below — never SELECT * — so the sensitive column cannot leak even by accident.
72+
function rowToDecision(row) {
73+
return {
74+
id: row.id,
75+
ts: row.ts,
76+
eventType: row.event_type,
77+
repoFullName: row.repo_full_name,
78+
actionClass: row.action_class,
79+
decision: row.decision,
80+
reason: row.reason,
81+
};
82+
}
83+
6984
/**
7085
* Opens the append-only governor ledger, creating the table on first use. Rows are returned in ascending `id`
7186
* order (insertion order). (#2328)
@@ -103,6 +118,15 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) {
103118
const readByRepoStatement = db.prepare(
104119
"SELECT * FROM governor_events WHERE repo_full_name = ? ORDER BY id ASC",
105120
);
121+
// Explicit named-column projection for the read-only decision log (#5159) — payload_json is intentionally
122+
// NOT in this list, so widening it would be a deliberate edit that the redaction test guards against.
123+
const decisionColumns = "id, ts, event_type, repo_full_name, action_class, decision, reason";
124+
const readDecisionsAllStatement = db.prepare(
125+
`SELECT ${decisionColumns} FROM governor_events ORDER BY id ASC`,
126+
);
127+
const readDecisionsByRepoStatement = db.prepare(
128+
`SELECT ${decisionColumns} FROM governor_events WHERE repo_full_name = ? ORDER BY id ASC`,
129+
);
106130

107131
return {
108132
dbPath: resolvedPath,
@@ -128,6 +152,14 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) {
128152
: readByRepoStatement.all(repoFullName);
129153
return rows.map(rowToEntry);
130154
},
155+
readGovernorDecisions(filter = {}) {
156+
const repoFullName = normalizeOptionalRepoFullName(filter.repoFullName);
157+
const rows =
158+
repoFullName === undefined
159+
? readDecisionsAllStatement.all()
160+
: readDecisionsByRepoStatement.all(repoFullName);
161+
return rows.map(rowToDecision);
162+
},
131163
close() {
132164
db.close();
133165
},
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
6+
import { afterEach, describe, expect, it } from "vitest";
7+
import { createMinerMcpServer } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js";
8+
import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js";
9+
10+
// gittensory_miner_get_governor_decisions (#5159). Driven against a REAL temp governor ledger (not a fake) so the
11+
// redaction assertion exercises the actual explicit-named-column SQL — it must fail if a future edit widens the
12+
// SELECT to include payload_json.
13+
14+
type Content = { content: Array<{ type: string; text?: string }> };
15+
type GovernorLedgerHandle = ReturnType<typeof initGovernorLedger>;
16+
17+
const roots: string[] = [];
18+
function tempGovernorLedger(): GovernorLedgerHandle {
19+
const root = mkdtempSync(join(tmpdir(), "gittensory-miner-mcp-governor-"));
20+
roots.push(root);
21+
return initGovernorLedger(join(root, "governor-ledger.sqlite3"));
22+
}
23+
afterEach(() => {
24+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
25+
});
26+
27+
function toolText(result: Content): string {
28+
const first = result.content[0];
29+
if (!first || first.type !== "text" || typeof first.text !== "string") {
30+
throw new Error("expected a single text content block");
31+
}
32+
return first.text;
33+
}
34+
35+
async function callGovernorDecisions(
36+
ledger: GovernorLedgerHandle,
37+
args: Record<string, unknown> = {},
38+
): Promise<unknown> {
39+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
40+
const client = new Client({ name: "miner-mcp-governor-test", version: "0.0.0" });
41+
await Promise.all([
42+
createMinerMcpServer({ initGovernorLedger: () => ledger }).connect(serverTransport),
43+
client.connect(clientTransport),
44+
]);
45+
const result = (await client.callTool({
46+
name: "gittensory_miner_get_governor_decisions",
47+
arguments: args,
48+
})) as Content;
49+
return JSON.parse(toolText(result));
50+
}
51+
52+
describe("gittensory_miner_get_governor_decisions (#5159)", () => {
53+
it("projects the decision columns and NEVER leaks payload / reputation / budget (redaction by construction)", async () => {
54+
const ledger = tempGovernorLedger();
55+
ledger.appendGovernorEvent({
56+
eventType: "denied",
57+
repoFullName: "acme/api",
58+
actionClass: "write",
59+
decision: "block",
60+
reason: "house rule violation",
61+
// Sensitive state that #5134 is expanding into payload_json — must never surface through this read tool.
62+
payload: { reputation: 0.2, self_plagiarism: true, budget: { remaining: 0 }, note: "secretish" },
63+
});
64+
65+
const decisions = (await callGovernorDecisions(ledger)) as Array<Record<string, unknown>>;
66+
expect(decisions).toHaveLength(1);
67+
expect(decisions[0]).toEqual({
68+
id: expect.any(Number),
69+
ts: expect.any(String),
70+
eventType: "denied",
71+
repoFullName: "acme/api",
72+
actionClass: "write",
73+
decision: "block",
74+
reason: "house rule violation",
75+
});
76+
for (const forbidden of ["payload", "payload_json", "reputation", "self_plagiarism", "selfPlagiarism", "budget"]) {
77+
expect(decisions[0]).not.toHaveProperty(forbidden);
78+
}
79+
// Belt-and-suspenders: the sensitive payload keys/values never appear anywhere in the serialized response.
80+
// (Only tokens that cannot legitimately occur in a projected column — "budget" is skipped because it may
81+
// appear in a decision `reason`; the not.toHaveProperty checks above already guard the payload key itself.)
82+
const serialized = JSON.stringify(decisions);
83+
for (const forbidden of ["reputation", "self_plagiarism", "secretish"]) {
84+
expect(serialized).not.toContain(forbidden);
85+
}
86+
});
87+
88+
it("filters by repoFullName", async () => {
89+
const ledger = tempGovernorLedger();
90+
for (const repo of ["acme/api", "acme/web"]) {
91+
ledger.appendGovernorEvent({
92+
eventType: "allowed",
93+
repoFullName: repo,
94+
actionClass: "analyze",
95+
decision: "allow",
96+
reason: "within budget",
97+
});
98+
}
99+
const decisions = (await callGovernorDecisions(ledger, { repoFullName: "acme/web" })) as Array<{
100+
repoFullName: string;
101+
}>;
102+
expect(decisions.map((decision) => decision.repoFullName)).toEqual(["acme/web"]);
103+
});
104+
105+
it("returns an empty array when nothing matches", async () => {
106+
const ledger = tempGovernorLedger();
107+
expect(await callGovernorDecisions(ledger, { repoFullName: "none/here" })).toEqual([]);
108+
});
109+
});

test/unit/miner-mcp-scaffold.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => {
9292
const { tools } = await client.listTools();
9393
expect(tools.map((tool) => tool.name).sort()).toEqual([
9494
"gittensory_miner_get_audit_feed",
95+
"gittensory_miner_get_governor_decisions",
9596
"gittensory_miner_get_plan",
9697
"gittensory_miner_get_portfolio_dashboard",
9798
"gittensory_miner_get_run_state",

0 commit comments

Comments
 (0)