From 4bc5c928f6afbf5b75294639430cd4ada316a368 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Fri, 29 May 2026 22:19:04 -0700 Subject: [PATCH 1/5] feat(mcp): parse validation command summaries Normalize local validation status, duration, and summaries before branch analysis so focused/passed checks count as evidence without sending raw logs. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 86 +++++++++++++++---- src/api/routes.ts | 4 +- src/mcp/server.ts | 4 +- src/openapi/schemas.ts | 10 ++- src/signals/local-branch.ts | 12 +-- test/unit/local-branch.test.ts | 29 +++++++ test/unit/mcp-cli.test.ts | 72 +++++++++++++++- 7 files changed, 190 insertions(+), 27 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index d15c474bd1..202cd4c669 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -109,8 +109,10 @@ const currentBranchShape = { .array( z.object({ command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }), ) .optional(), @@ -624,7 +626,7 @@ Source upload remains disabled. function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "scenarioNote"]); + const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -908,29 +910,39 @@ function getApiToken() { } function validationFromOptions(options) { - const direct = (options.validation ?? []).map((entry) => { - const [statusOrCommand, commandOrSummary, ...summaryParts] = String(entry).split("|"); - const status = isValidationStatus(statusOrCommand) ? statusOrCommand : "not_run"; - const command = isValidationStatus(statusOrCommand) ? commandOrSummary : statusOrCommand; - return stripUndefined({ - command: command?.trim(), - status, - summary: summaryParts.join("|").trim() || (isValidationStatus(statusOrCommand) ? undefined : commandOrSummary?.trim()), - }); - }); + const direct = (options.validation ?? []).map(parseValidationEntry); const commands = options.validationCommand ?? []; const statuses = options.validationStatus ?? []; const summaries = options.validationSummary ?? []; + const durations = options.validationDuration ?? []; const expanded = commands.map((command, index) => stripUndefined({ - command, - status: isValidationStatus(statuses[index]) ? statuses[index] : "not_run", - summary: summaries[index], + command: sanitizeValidationText(command, 160), + status: normalizeValidationStatus(statuses[index]) ?? "not_run", + summary: sanitizeValidationText(summaries[index]), + durationMs: parseDurationMs(durations[index]), + exitCode: inferValidationExitCode(statuses[index]), }), ); return [...direct, ...expanded].filter((entry) => typeof entry.command === "string" && entry.command.length > 0); } +function parseValidationEntry(entry) { + const parts = String(entry ?? "").split("|").map((part) => part.trim()); + const explicitStatus = normalizeValidationStatus(parts[0]); + const command = explicitStatus ? parts[1] : parts[0]; + const rest = explicitStatus ? parts.slice(2) : parts.slice(1); + const durationMs = parseDurationMs(rest[0]); + const summaryParts = durationMs !== undefined ? rest.slice(1) : rest; + return stripUndefined({ + command: sanitizeValidationText(command, 160), + status: explicitStatus ?? normalizeValidationStatus(summaryParts.join(" ")) ?? "not_run", + summary: sanitizeValidationText(summaryParts.join("|")), + durationMs, + exitCode: inferValidationExitCode(explicitStatus ?? summaryParts.join(" ")), + }); +} + function optionalInteger(value) { if (value === undefined || value === true) return undefined; const parsed = Number(value); @@ -944,7 +956,49 @@ function optionalNumber(value) { } function isValidationStatus(value) { - return value === "passed" || value === "failed" || value === "not_run"; + return Boolean(normalizeValidationStatus(value)); +} + +function normalizeValidationStatus(value) { + const text = String(value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); + if (["passed", "pass", "success", "ok", "exit_0", "0"].includes(text)) return "passed"; + if (["failed", "fail", "failure", "error", "nonzero", "non_zero"].includes(text) || /^exit_[1-9]\d*$/.test(text)) return "failed"; + if (["not_run", "notrun", "not_ran", "pending"].includes(text)) return "not_run"; + if (["skipped", "skip"].includes(text)) return "skipped"; + if (["focused", "focus"].includes(text)) return "focused"; + if (["unknown", "unclear"].includes(text)) return "unknown"; + return undefined; +} + +function inferValidationExitCode(value) { + const text = String(value ?? "").trim().toLowerCase(); + const match = text.match(/\b(?:exit|status|code)[\s:_-]*(\d{1,3})\b/); + if (match) return Number(match[1]); + const status = normalizeValidationStatus(text); + if (status === "passed" || status === "focused") return 0; + if (status === "failed") return 1; + return undefined; +} + +function parseDurationMs(value) { + const text = String(value ?? "").trim().toLowerCase(); + const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins)?$/); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return undefined; + const unit = match[2] ?? "ms"; + const multiplier = unit.startsWith("m") && unit !== "ms" ? 60000 : unit.startsWith("s") ? 1000 : 1; + return Math.round(amount * multiplier); +} + +function sanitizeValidationText(value, maxLength = 240) { + const text = String(value ?? "").replace(/[\r\n\t]+/g, " ").trim(); + if (!text) return undefined; + const redacted = text + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, "") + .replace(/(^|[\s"'`=])\/(?:[^\s"'`,;)]+(?:\/[^\s"'`,;)]+)*)/g, (_, prefix) => `${prefix}`) + .replace(/\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b/gi, "[redacted]"); + return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 3)}...`; } function clientSnippet(client, command) { diff --git a/src/api/routes.ts b/src/api/routes.ts index f14f12ac4a..61e7735b4a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -144,8 +144,10 @@ const localBranchChangedFileSchema = z const localBranchValidationSchema = z .object({ command: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }) .strict(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bcf3164680..0542615b8c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -137,8 +137,10 @@ const localBranchAnalysisShape = { z .object({ command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }) .strict(), ) diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0eccc0eadc..0bc37d2f7e 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1299,7 +1299,15 @@ export const LocalBranchAnalysisSchema = z passed: z.number(), failed: z.number(), notRun: z.number(), - commands: z.array(z.object({ command: z.string(), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().optional() })), + commands: z.array( + z.object({ + command: z.string(), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), + summary: z.string().optional(), + durationMs: z.number().optional(), + exitCode: z.number().optional(), + }), + ), }), publicSafeWarnings: z.array(z.string()), }), diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 8f6b61ebc1..5b39a42a38 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -27,8 +27,10 @@ export type LocalBranchChangedFile = { export type LocalBranchValidation = { command: string; - status: "passed" | "failed" | "not_run"; + status: "passed" | "failed" | "not_run" | "skipped" | "focused" | "unknown"; summary?: string | undefined; + durationMs?: number | undefined; + exitCode?: number | undefined; }; export type LocalBranchScorer = { @@ -640,7 +642,7 @@ function buildPublicSafePrPacket(args: { ); const validationLines = args.validationSummary.commands.length > 0 - ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.summary ? ` (${entry.summary})` : ""}`) + ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.durationMs !== undefined ? ` [${entry.durationMs}ms]` : ""}${entry.summary ? ` (${entry.summary})` : ""}`) : ["- Not supplied yet."]; const bodySections = [ { @@ -699,16 +701,16 @@ function renderPrPacketMarkdown(title: string, sections: Array<{ heading: string function summarizeValidation(validation: LocalBranchValidation[]): LocalBranchAnalysis["prPacket"]["validationSummary"] { return { - passed: validation.filter((entry) => entry.status === "passed").length, + passed: validation.filter((entry) => entry.status === "passed" || entry.status === "focused").length, failed: validation.filter((entry) => entry.status === "failed").length, - notRun: validation.filter((entry) => entry.status === "not_run").length, + notRun: validation.filter((entry) => entry.status === "not_run" || entry.status === "skipped" || entry.status === "unknown").length, commands: validation, }; } function validationEvidence(validation: LocalBranchValidation[] | undefined): string[] { return (validation ?? []) - .filter((entry) => entry.status === "passed") + .filter((entry) => entry.status === "passed" || entry.status === "focused") .map((entry) => entry.command); } diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 81863cb06e..448ac22d8c 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -294,6 +294,35 @@ describe("local branch analysis", () => { expect(analysis.recommendedRerunCondition).toMatch(/git fetch origin/i); }); + it("treats focused validation as evidence and failed validation as actionable", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [{ path: "internal/entity/model.go", additions: 10, deletions: 2, status: "modified" }], + validation: [ + { command: "go test ./internal/entity", status: "focused", durationMs: 1240, exitCode: 0, summary: "focused regression passed" }, + { command: "npm run lint", status: "failed", durationMs: 2000, exitCode: 1, summary: "raw_trust=0.4 /Users/example/log.txt" }, + { command: "npm run e2e", status: "skipped", summary: "not relevant for this fixture" }, + ], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Entity model edge case", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.prPacket.validationSummary).toMatchObject({ passed: 1, failed: 1, notRun: 1 }); + expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("missing_test_evidence"); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "failed_local_validation" })])); + expect(analysis.prPacket.markdown).toContain("- focused: go test ./internal/entity [1240ms] (focused regression passed)"); + expect(analysis.prPacket.markdown).not.toMatch(/raw_trust|\/Users\/example/i); + }); + it("includes public-safe overlap caution and hides local absolute paths", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index c21fde4b50..f6c8099012 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync } from "node:child_process"; -import { createServer, type Server } from "node:http"; +import { createServer, type IncomingMessage, type Server } from "node:http"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -345,6 +345,57 @@ describe("gittensory-mcp CLI", () => { } }, 10000); + it("sends bounded structured validation summaries without local logs", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + [ + "agent", + "packet", + "--login", + "oktofeesh1", + "--cwd", + tempDir, + "--base", + "HEAD", + "--validation", + "focused|npm run test:unit|1234ms|unit passed raw_trust=0.4 /Users/example/log.txt", + "--validation-command", + "npm run lint", + "--validation-status", + "exit 1", + "--validation-duration", + "2s", + "--validation-summary", + "lint failed at /tmp/raw.log", + "--json", + ], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + const packet = requests[0] as { validation: Array<{ command: string; status: string; durationMs?: number; exitCode?: number; summary?: string }> }; + expect(packet.validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "npm run test:unit", status: "focused", durationMs: 1234, exitCode: 0 }), + expect.objectContaining({ command: "npm run lint", status: "failed", durationMs: 2000, exitCode: 1 }), + ]), + ); + expect(JSON.stringify(packet.validation)).not.toMatch(/raw_trust|\/Users\/example|\/tmp\/raw/i); + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); @@ -392,8 +443,8 @@ function git(cwd: string, ...args: string[]) { execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } -async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string } = {}) { - server = createServer((request, response) => { +async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void } = {}) { + server = createServer(async (request, response) => { response.setHeader("content-type", "application/json"); if (request.url && request.url.includes("gittensory-mcp/latest")) { if (options.npmStatus && options.npmStatus >= 400) { @@ -421,6 +472,7 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi return; } if (request.url === "/v1/agent/prepare-pr-packet" && request.method === "POST") { + options.onPacketRequest?.(await readJsonRequest(request)); response.end(JSON.stringify(agentPacketFixture(options.packetMarkdown))); return; } @@ -433,6 +485,20 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi return `http://127.0.0.1:${address.port}`; } +function readJsonRequest(request: IncomingMessage) { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); + } catch { + resolve({}); + } + }); + }); +} + function agentPacketFixture(markdown = "# Public-safe PR packet\n\n## Linked Context\n- Closes #39\n\n## Validation\n- passed: npm test (packet tests passed)\n") { return { ...agentFixture(), From 3efe1fb067d200d1c6781e3cc0be68e820f83cdb Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Fri, 29 May 2026 22:29:51 -0700 Subject: [PATCH 2/5] test(readiness): keep freshness fixtures current --- test/integration/api.test.ts | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index cb45405b5d..de4f64d520 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1140,7 +1140,7 @@ describe("api routes", () => { fetchedCount: 2, expectedCount: 2, pageCount: 1, - completedAt: "2026-05-23T00:00:00.000Z", + completedAt: new Date().toISOString(), warnings: [], }); const refreshingReadiness = await app.request("/v1/readiness", { headers: apiHeaders(refreshingEnv) }, refreshingEnv); @@ -2351,6 +2351,9 @@ async function mcpJson(response: Response): Promise { } async function seedSignalData(env: Env): Promise { + const freshAt = new Date().toISOString(); + const previousFreshAt = new Date(Date.now() - 60_000).toISOString(); + await upsertInstallation(env, { installation: { id: 123, @@ -2371,7 +2374,7 @@ async function seedSignalData(env: Env): Promise { missingEvents: [], permissions: { metadata: "read", pull_requests: "read", issues: "write" }, events: ["issues", "pull_request", "repository"], - checkedAt: "2026-05-23T00:00:00.000Z", + checkedAt: freshAt, }); const snapshot = normalizeRegistryPayload( { @@ -2384,7 +2387,7 @@ async function seedSignalData(env: Env): Promise { }, }, { kind: "raw-github", url: "https://example.test/master_repositories.json" }, - "2026-05-23T00:00:00.000Z", + freshAt, ); await persistRegistrySnapshot( env, @@ -2399,7 +2402,7 @@ async function seedSignalData(env: Env): Promise { }, }, { kind: "raw-github", url: "https://example.test/old_master_repositories.json" }, - "2026-05-22T00:00:00.000Z", + previousFreshAt, ), ); await persistRegistrySnapshot(env, snapshot); @@ -2414,7 +2417,7 @@ async function seedSignalData(env: Env): Promise { id: "scoring-1", sourceKind: "test", sourceUrl: "fixture://scoring", - fetchedAt: "2026-05-23T00:00:00.000Z", + fetchedAt: freshAt, activeModel: "current_density_model", constants: { OSS_EMISSION_SHARE: 0.9, @@ -2459,7 +2462,7 @@ async function seedSignalData(env: Env): Promise { closedUnmergedPullRequestsTotal: 0, labelsTotal: 2, sourceKind: "github", - fetchedAt: "2026-05-23T00:00:00.000Z", + fetchedAt: freshAt, payload: {}, }); await Promise.all( @@ -2482,7 +2485,7 @@ async function seedSignalData(env: Env): Promise { fetchedCount: record.fetchedCount, expectedCount: record.expectedCount, pageCount: 1, - completedAt: "2026-05-23T00:00:00.000Z", + completedAt: freshAt, warnings: [], }), ), @@ -2516,7 +2519,7 @@ async function seedSignalData(env: Env): Promise { missingEvents: [], permissions: { metadata: "read", pull_requests: "read", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository"], - checkedAt: "2026-05-23T00:00:00.000Z", + checkedAt: freshAt, }); await upsertIssueFromGitHub(env, "entrius/allways-ui", { number: 7, @@ -2552,10 +2555,10 @@ async function seedSignalData(env: Env): Promise { repoFullName: "entrius/allways-ui", pullNumber: 12, status: "complete", - filesSyncedAt: "2026-05-23T00:00:00.000Z", - reviewsSyncedAt: "2026-05-23T00:00:00.000Z", - checksSyncedAt: "2026-05-23T00:00:00.000Z", - lastSyncedAt: "2026-05-23T00:00:00.000Z", + filesSyncedAt: freshAt, + reviewsSyncedAt: freshAt, + checksSyncedAt: freshAt, + lastSyncedAt: freshAt, }); await upsertPullRequestFile(env, { repoFullName: "entrius/allways-ui", @@ -2600,10 +2603,10 @@ async function seedSignalData(env: Env): Promise { repoFullName: "entrius/allways-ui", pullNumber: 13, status: "complete", - filesSyncedAt: "2026-05-23T00:00:00.000Z", - reviewsSyncedAt: "2026-05-23T00:00:00.000Z", - checksSyncedAt: "2026-05-23T00:00:00.000Z", - lastSyncedAt: "2026-05-23T00:00:00.000Z", + filesSyncedAt: freshAt, + reviewsSyncedAt: freshAt, + checksSyncedAt: freshAt, + lastSyncedAt: freshAt, }); await upsertRecentMergedPullRequest(env, { repoFullName: "entrius/allways-ui", From 972dca6275a9387038a74e1d3cd823b4ecb302e1 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:28 -0700 Subject: [PATCH 3/5] fix(mcp): harden validation summary parsing --- packages/gittensory-mcp/bin/gittensory-mcp.js | 32 +++++++++----- test/unit/mcp-cli.test.ts | 44 ++++++++++++++++++- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 202cd4c669..8941c08efd 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -916,12 +916,11 @@ function validationFromOptions(options) { const summaries = options.validationSummary ?? []; const durations = options.validationDuration ?? []; const expanded = commands.map((command, index) => - stripUndefined({ - command: sanitizeValidationText(command, 160), - status: normalizeValidationStatus(statuses[index]) ?? "not_run", - summary: sanitizeValidationText(summaries[index]), - durationMs: parseDurationMs(durations[index]), - exitCode: inferValidationExitCode(statuses[index]), + validationEntry({ + command, + statusText: statuses[index], + summaryText: summaries[index], + durationText: durations[index], }), ); return [...direct, ...expanded].filter((entry) => typeof entry.command === "string" && entry.command.length > 0); @@ -934,12 +933,23 @@ function parseValidationEntry(entry) { const rest = explicitStatus ? parts.slice(2) : parts.slice(1); const durationMs = parseDurationMs(rest[0]); const summaryParts = durationMs !== undefined ? rest.slice(1) : rest; + return validationEntry({ + command, + statusText: explicitStatus ?? summaryParts.join(" "), + summaryText: summaryParts.join("|"), + durationMs, + }); +} + +function validationEntry({ command, statusText, summaryText, durationText, durationMs }) { + const exitCode = inferValidationExitCode(statusText); + const status = normalizeValidationStatus(statusText) ?? (exitCode !== undefined ? (exitCode === 0 ? "passed" : "failed") : "not_run"); return stripUndefined({ command: sanitizeValidationText(command, 160), - status: explicitStatus ?? normalizeValidationStatus(summaryParts.join(" ")) ?? "not_run", - summary: sanitizeValidationText(summaryParts.join("|")), - durationMs, - exitCode: inferValidationExitCode(explicitStatus ?? summaryParts.join(" ")), + status, + summary: sanitizeValidationText(summaryText), + durationMs: durationMs ?? parseDurationMs(durationText), + exitCode, }); } @@ -995,7 +1005,7 @@ function sanitizeValidationText(value, maxLength = 240) { const text = String(value ?? "").replace(/[\r\n\t]+/g, " ").trim(); if (!text) return undefined; const redacted = text - .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, "") + .replace(/(?:~\/|[A-Za-z]:[\\/])[^\s"'`,;)]+/g, "") .replace(/(^|[\s"'`=])\/(?:[^\s"'`,;)]+(?:\/[^\s"'`,;)]+)*)/g, (_, prefix) => `${prefix}`) .replace(/\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b/gi, "[redacted]"); return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 3)}...`; diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index f6c8099012..b8b2416a80 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -372,11 +372,11 @@ describe("gittensory-mcp CLI", () => { "--validation-command", "npm run lint", "--validation-status", - "exit 1", + "exit code 1", "--validation-duration", "2s", "--validation-summary", - "lint failed at /tmp/raw.log", + "lint failed at C:/Users/alice/raw.log and /tmp/raw.log", "--json", ], { @@ -394,6 +394,46 @@ describe("gittensory-mcp CLI", () => { ]), ); expect(JSON.stringify(packet.validation)).not.toMatch(/raw_trust|\/Users\/example|\/tmp\/raw/i); + expect(JSON.stringify(packet.validation)).not.toMatch(/C:\/Users|alice/i); + }); + + it("classifies nonzero validation status phrases as failed", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + [ + "agent", + "packet", + "--login", + "oktofeesh1", + "--cwd", + tempDir, + "--base", + "HEAD", + "--validation-command", + "npm test", + "--validation-status", + "status: 2", + "--json", + ], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + const packet = requests[0] as { validation: Array<{ command: string; status: string; exitCode?: number }> }; + expect(packet.validation).toEqual(expect.arrayContaining([expect.objectContaining({ command: "npm test", status: "failed", exitCode: 2 })])); }); it("rejects unsupported client snippets", () => { From 3f36895720cc813a778c40aea75bd3f6fdf285c6 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Sat, 30 May 2026 01:16:52 -0700 Subject: [PATCH 4/5] fix(mcp): classify validation exit summaries --- packages/gittensory-mcp/bin/gittensory-mcp.js | 49 +++++++++++--- test/unit/mcp-cli.test.ts | 65 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 8941c08efd..c87a54cbbe 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -931,19 +931,28 @@ function parseValidationEntry(entry) { const explicitStatus = normalizeValidationStatus(parts[0]); const command = explicitStatus ? parts[1] : parts[0]; const rest = explicitStatus ? parts.slice(2) : parts.slice(1); - const durationMs = parseDurationMs(rest[0]); - const summaryParts = durationMs !== undefined ? rest.slice(1) : rest; + const inferredStatusText = !explicitStatus && isValidationStatusLike(rest[0]) ? rest[0] : undefined; + const detailParts = inferredStatusText ? rest.slice(1) : rest; + const durationMs = parseDurationMs(detailParts[0]); + const summaryParts = durationMs !== undefined ? detailParts.slice(1) : detailParts; return validationEntry({ command, - statusText: explicitStatus ?? summaryParts.join(" "), + statusText: explicitStatus ?? inferredStatusText, summaryText: summaryParts.join("|"), durationMs, }); } function validationEntry({ command, statusText, summaryText, durationText, durationMs }) { - const exitCode = inferValidationExitCode(statusText); - const status = normalizeValidationStatus(statusText) ?? (exitCode !== undefined ? (exitCode === 0 ? "passed" : "failed") : "not_run"); + const statusSource = nonEmptyString(statusText); + const summarySource = statusSource ? undefined : nonEmptyString(summaryText); + const exitCode = + inferValidationExitCode(statusSource, { allowBareCode: true, allowGenericStatus: true }) ?? + inferValidationExitCode(summarySource, { allowBareCode: false, allowGenericStatus: false }); + const status = + normalizeValidationStatus(statusSource) ?? + normalizeSummaryValidationStatus(summarySource) ?? + (exitCode !== undefined ? (exitCode === 0 ? "passed" : "failed") : "not_run"); return stripUndefined({ command: sanitizeValidationText(command, 160), status, @@ -972,7 +981,7 @@ function isValidationStatus(value) { function normalizeValidationStatus(value) { const text = String(value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); if (["passed", "pass", "success", "ok", "exit_0", "0"].includes(text)) return "passed"; - if (["failed", "fail", "failure", "error", "nonzero", "non_zero"].includes(text) || /^exit_[1-9]\d*$/.test(text)) return "failed"; + if (["failed", "fail", "failure", "error", "nonzero", "non_zero"].includes(text) || /^exit_[1-9]\d*$/.test(text) || /^[1-9]\d*$/.test(text)) return "failed"; if (["not_run", "notrun", "not_ran", "pending"].includes(text)) return "not_run"; if (["skipped", "skip"].includes(text)) return "skipped"; if (["focused", "focus"].includes(text)) return "focused"; @@ -980,16 +989,40 @@ function normalizeValidationStatus(value) { return undefined; } -function inferValidationExitCode(value) { +function isValidationStatusLike(value) { + return Boolean( + normalizeValidationStatus(value) ?? + inferValidationExitCode(value, { allowBareCode: true, allowGenericStatus: true }), + ); +} + +function inferValidationExitCode(value, options = {}) { const text = String(value ?? "").trim().toLowerCase(); - const match = text.match(/\b(?:exit|status|code)[\s:_-]*(\d{1,3})\b/); + const allowBareCode = options.allowBareCode === true; + const allowGenericStatus = options.allowGenericStatus === true; + if (allowBareCode && /^\d{1,3}$/.test(text)) return Number(text); + const processExitPattern = /\b(?:exit(?:ed)?(?:\s+(?:code|status))?|exitcode|process\s+(?:exit(?:ed)?|status|code)|command\s+(?:exit(?:ed)?|status|code)|shell\s+(?:exit(?:ed)?|status|code))[\s:_-]*(\d{1,3})\b/; + const genericStatusPattern = /^(?:status|code)[\s:_-]*(\d{1,3})\b/; + const match = text.match(processExitPattern) ?? (allowGenericStatus ? text.match(genericStatusPattern) : null); if (match) return Number(match[1]); + if (!allowBareCode && /^\d{1,3}$/.test(text)) return undefined; const status = normalizeValidationStatus(text); if (status === "passed" || status === "focused") return 0; if (status === "failed") return 1; return undefined; } +function normalizeSummaryValidationStatus(value) { + const text = nonEmptyString(value); + if (!text || /^\d{1,3}$/.test(text)) return undefined; + return normalizeValidationStatus(text); +} + +function nonEmptyString(value) { + const text = String(value ?? "").trim(); + return text ? text : undefined; +} + function parseDurationMs(value) { const text = String(value ?? "").trim().toLowerCase(); const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins)?$/); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index b8b2416a80..5806fce861 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -436,6 +436,44 @@ describe("gittensory-mcp CLI", () => { expect(packet.validation).toEqual(expect.arrayContaining([expect.objectContaining({ command: "npm test", status: "failed", exitCode: 2 })])); }); + it("classifies bare nonzero validation statuses as failed", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, [ + "--validation", + "npm test|1", + "--validation-command", + "npm run lint", + "--validation-status", + "2", + ]); + + expect(validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "npm test", status: "failed", exitCode: 1 }), + expect.objectContaining({ command: "npm run lint", status: "failed", exitCode: 2 }), + ]), + ); + }); + + it("does not infer HTTP status summaries as process exit codes", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, ["--validation", "npm run e2e|HTTP status 200 OK"]); + + expect(validation).toEqual( + expect.arrayContaining([expect.objectContaining({ command: "npm run e2e", status: "not_run", summary: "HTTP status 200 OK" })]), + ); + expect(validation[0]).not.toHaveProperty("exitCode"); + }); + + it("infers expanded validation failures from summaries when status is absent", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, ["--validation-command", "npm test", "--validation-summary", "exit code 1"]); + + expect(validation).toEqual( + expect.arrayContaining([expect.objectContaining({ command: "npm test", status: "failed", exitCode: 1, summary: "exit code 1" })]), + ); + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); @@ -483,6 +521,33 @@ function git(cwd: string, ...args: string[]) { execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } +function createPacketRepo() { + const cwd = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(cwd, "init"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "user.name", "Gittensory Test"); + git(cwd, "config", "commit.gpgsign", "false"); + git(cwd, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(cwd, "README.md"), "fixture\n"); + git(cwd, "add", "README.md"); + git(cwd, "commit", "-m", "initial commit"); + return cwd; +} + +async function capturePacketValidation(tempDir: string, validationArgs: string[]) { + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + ["agent", "packet", "--login", "oktofeesh1", "--cwd", tempDir, "--base", "HEAD", ...validationArgs, "--json"], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + return (requests[0] as { validation: Array<{ command: string; status: string; exitCode?: number; summary?: string }> }).validation; +} + async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void } = {}) { server = createServer(async (request, response) => { response.setHeader("content-type", "application/json"); From 158bf82dfb1445f6c62689e81a77bb9174e50c4c Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Sat, 30 May 2026 01:33:12 -0700 Subject: [PATCH 5/5] fix(mcp): redact validation privacy tokens --- packages/gittensory-mcp/bin/gittensory-mcp.js | 19 +++++++++++---- test/unit/mcp-cli.test.ts | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index c87a54cbbe..c71bc77b80 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1037,13 +1037,24 @@ function parseDurationMs(value) { function sanitizeValidationText(value, maxLength = 240) { const text = String(value ?? "").replace(/[\r\n\t]+/g, " ").trim(); if (!text) return undefined; - const redacted = text - .replace(/(?:~\/|[A-Za-z]:[\\/])[^\s"'`,;)]+/g, "") - .replace(/(^|[\s"'`=])\/(?:[^\s"'`,;)]+(?:\/[^\s"'`,;)]+)*)/g, (_, prefix) => `${prefix}`) - .replace(/\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b/gi, "[redacted]"); + const redacted = redactPrivateValidationMetrics(redactLocalValidationPaths(text)); return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 3)}...`; } +function redactLocalValidationPaths(text) { + const pathSegment = "[^\\\\/\\s\"'`,;)]+(?:\\s+[^\\\\/\\s\"'`,;)]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)]+"; + const localPathPattern = new RegExp(`(^|[\\s"'\\\`=])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text.replace(localPathPattern, (_, prefix) => `${prefix}`); +} + +function redactPrivateValidationMetrics(text) { + return text.replace( + /\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b(?:\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'`,;)]+))?/gi, + "[redacted]", + ); +} + function clientSnippet(client, command) { if (client === "codex") return `[mcp_servers.gittensory]\ncommand = ${JSON.stringify(command)}\nargs = ["--stdio"]`; if (client === "claude" || client === "cursor") { diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 5806fce861..92db7cd002 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -474,6 +474,29 @@ describe("gittensory-mcp CLI", () => { ); }); + it("redacts space-containing local paths and private metric values from validation text", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, [ + "--validation-command", + "node /Users/Alice Smith/project/run.js", + "--validation-status", + "failed", + "--validation-summary", + "log=C:\\Users\\Alice Smith\\raw.log raw_trust=0.72 private_reviewability=ready", + ]); + + expect(validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "node ", + status: "failed", + summary: "log= [redacted] [redacted]", + }), + ]), + ); + expect(JSON.stringify(validation)).not.toMatch(/Alice Smith|Smith[\\/]|raw\.log|0\.72|ready|\[redacted\]=/); + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); });