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
123 changes: 113 additions & 10 deletions actions/setup/js/parse_mcp_gateway_log.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const { ERR_PARSE } = require("./error_codes.cjs");
/**
* Parses MCP gateway logs and creates a step summary
* Log file locations:
* - /tmp/gh-aw/mcp-logs/gateway.md (markdown summary from gateway, preferred)
* - /tmp/gh-aw/mcp-logs/gateway.jsonl (structured JSONL log, parsed for DIFC_FILTERED events)
* - /tmp/gh-aw/mcp-logs/gateway.md (markdown summary from gateway, preferred for general content)
* - /tmp/gh-aw/mcp-logs/gateway.log (main gateway log, fallback)
* - /tmp/gh-aw/mcp-logs/stderr.log (stderr output, fallback)
*/
Expand All @@ -22,6 +23,68 @@ function printAllGatewayFiles() {
displayDirectories(gatewayDirs, 64 * 1024);
}

/**
* Parses gateway.jsonl content and extracts DIFC_FILTERED events
* @param {string} jsonlContent - The gateway.jsonl file content
* @returns {Array<Object>} Array of DIFC_FILTERED event objects
*/
function parseGatewayJsonlForDifcFiltered(jsonlContent) {
const filteredEvents = [];
const lines = jsonlContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.includes("DIFC_FILTERED")) continue;
try {
const entry = JSON.parse(trimmed);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot do a quick string contains before parsing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3edcb80. parseGatewayJsonlForDifcFiltered now short-circuits with !trimmed.includes("DIFC_FILTERED") before calling JSON.parse.

if (entry.type === "DIFC_FILTERED") {
filteredEvents.push(entry);
}
} catch {
// skip malformed lines
}
}
return filteredEvents;
}

/**
* Generates a markdown summary section for DIFC_FILTERED events
* @param {Array<Object>} filteredEvents - Array of DIFC_FILTERED event objects
* @returns {string} Markdown section, or empty string if no events
*/
function generateDifcFilteredSummary(filteredEvents) {
if (!filteredEvents || filteredEvents.length === 0) return "";

const lines = [];
lines.push("<details>");
lines.push(`<summary>🔒 DIFC Filtered Events (${filteredEvents.length})</summary>\n`);
lines.push("");
lines.push("The following tool calls were blocked by DIFC integrity or secrecy checks:\n");
lines.push("");
lines.push("| Time | Server | Tool | Reason | User | Resource |");
lines.push("|------|--------|------|--------|------|----------|");

for (const event of filteredEvents) {
const time = event.timestamp ? event.timestamp.replace("T", " ").replace(/\.\d+Z$/, "Z") : "-";
const server = event.server_id || "-";
const tool = event.tool_name ? `\`${event.tool_name}\`` : "-";
const reason = (event.reason || "-").replace(/\n/g, " ").replace(/\|/g, "\\|");
const user = event.author_login ? `${event.author_login} (${event.author_association || "NONE"})` : "-";
let resource;
if (event.html_url) {
const lastSegment = event.html_url.split("/").filter(Boolean).pop();
const label = event.number ? `#${event.number}` : lastSegment || event.html_url;
resource = `[${label}](${event.html_url})`;
} else {
resource = event.description || "-";
}
lines.push(`| ${time} | ${server} | ${tool} | ${reason} | ${user} | ${resource} |`);
}

lines.push("");
lines.push("</details>\n");
return lines.join("\n");
}

/**
* Main function to parse and display MCP gateway logs
*/
Expand All @@ -30,18 +93,49 @@ async function main() {
// First, print all gateway-related files for debugging
printAllGatewayFiles();

const gatewayJsonlPath = "/tmp/gh-aw/mcp-logs/gateway.jsonl";
const rpcMessagesPath = "/tmp/gh-aw/mcp-logs/rpc-messages.jsonl";
const gatewayMdPath = "/tmp/gh-aw/mcp-logs/gateway.md";
const gatewayLogPath = "/tmp/gh-aw/mcp-logs/gateway.log";
const stderrLogPath = "/tmp/gh-aw/mcp-logs/stderr.log";

// First, try to read gateway.md if it exists
// Parse DIFC_FILTERED events from gateway.jsonl (preferred) or rpc-messages.jsonl (fallback).
// Both files use the same JSONL format with DIFC_FILTERED entries interleaved.
let difcFilteredEvents = [];
if (fs.existsSync(gatewayJsonlPath)) {
const jsonlContent = fs.readFileSync(gatewayJsonlPath, "utf8");
core.info(`Found gateway.jsonl (${jsonlContent.length} bytes)`);
difcFilteredEvents = parseGatewayJsonlForDifcFiltered(jsonlContent);
if (difcFilteredEvents.length > 0) {
core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in gateway.jsonl`);
}
} else if (fs.existsSync(rpcMessagesPath)) {
const jsonlContent = fs.readFileSync(rpcMessagesPath, "utf8");
core.info(`No gateway.jsonl found; scanning rpc-messages.jsonl (${jsonlContent.length} bytes) for DIFC_FILTERED events`);
difcFilteredEvents = parseGatewayJsonlForDifcFiltered(jsonlContent);
if (difcFilteredEvents.length > 0) {
core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in rpc-messages.jsonl`);
}
} else {
core.info(`No gateway.jsonl or rpc-messages.jsonl found for DIFC_FILTERED scanning`);
}

// Try to read gateway.md if it exists (preferred for general gateway summary)
if (fs.existsSync(gatewayMdPath)) {
const gatewayMdContent = fs.readFileSync(gatewayMdPath, "utf8");
if (gatewayMdContent && gatewayMdContent.trim().length > 0) {
core.info(`Found gateway.md (${gatewayMdContent.length} bytes)`);

// Write the markdown directly to the step summary
core.summary.addRaw(gatewayMdContent).write();
core.summary.addRaw(gatewayMdContent.endsWith("\n") ? gatewayMdContent : gatewayMdContent + "\n");

// Append DIFC_FILTERED section if any events found
if (difcFilteredEvents.length > 0) {
const difcSummary = generateDifcFilteredSummary(difcFilteredEvents);
core.summary.addRaw(difcSummary);
}

core.summary.write();
return;
}
} else {
Expand All @@ -68,19 +162,26 @@ async function main() {
core.info(`No stderr.log found at: ${stderrLogPath}`);
}

// If neither log file has content, nothing to do
if ((!gatewayLogContent || gatewayLogContent.trim().length === 0) && (!stderrLogContent || stderrLogContent.trim().length === 0)) {
// If no legacy log content and no DIFC events, nothing to do
if ((!gatewayLogContent || gatewayLogContent.trim().length === 0) && (!stderrLogContent || stderrLogContent.trim().length === 0) && difcFilteredEvents.length === 0) {
core.info("MCP gateway log files are empty or missing");
return;
}

// Generate plain text summary for core.info
const plainTextSummary = generatePlainTextLegacySummary(gatewayLogContent, stderrLogContent);
core.info(plainTextSummary);
if ((gatewayLogContent && gatewayLogContent.trim().length > 0) || (stderrLogContent && stderrLogContent.trim().length > 0)) {
const plainTextSummary = generatePlainTextLegacySummary(gatewayLogContent, stderrLogContent);
core.info(plainTextSummary);
}

// Generate step summary for both logs
const summary = generateGatewayLogSummary(gatewayLogContent, stderrLogContent);
core.summary.addRaw(summary).write();
// Generate step summary: legacy logs + DIFC filtered section
const legacySummary = generateGatewayLogSummary(gatewayLogContent, stderrLogContent);
const difcSummary = generateDifcFilteredSummary(difcFilteredEvents);
const fullSummary = [legacySummary, difcSummary].filter(s => s.length > 0).join("\n");

if (fullSummary.length > 0) {
core.summary.addRaw(fullSummary).write();
}
} catch (error) {
core.setFailed(`${ERR_PARSE}: ${getErrorMessage(error)}`);
}
Expand Down Expand Up @@ -195,6 +296,8 @@ if (typeof module !== "undefined" && module.exports) {
generateGatewayLogSummary,
generatePlainTextGatewaySummary,
generatePlainTextLegacySummary,
parseGatewayJsonlForDifcFiltered,
generateDifcFilteredSummary,
printAllGatewayFiles,
};
}
Expand Down
156 changes: 155 additions & 1 deletion actions/setup/js/parse_mcp_gateway_log.test.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { generateGatewayLogSummary, generatePlainTextGatewaySummary, generatePlainTextLegacySummary, printAllGatewayFiles } = require("./parse_mcp_gateway_log.cjs");
const { generateGatewayLogSummary, generatePlainTextGatewaySummary, generatePlainTextLegacySummary, parseGatewayJsonlForDifcFiltered, generateDifcFilteredSummary, printAllGatewayFiles } = require("./parse_mcp_gateway_log.cjs");

describe("parse_mcp_gateway_log", () => {
// Note: The main() function now checks for gateway.md first before falling back to log files.
Expand Down Expand Up @@ -569,4 +569,158 @@ Some content here.`;
}
});
});

describe("parseGatewayJsonlForDifcFiltered", () => {
test("extracts DIFC_FILTERED events from JSONL content", () => {
const jsonlContent = [
JSON.stringify({
timestamp: "2026-03-18T17:30:00.123456789Z",
type: "DIFC_FILTERED",
server_id: "github",
tool_name: "list_issues",
description: "resource:list_issues",
reason: "Integrity check failed, missingTags=[approved:github/copilot-indexing-issues-prs]",
secrecy_tags: ["private:github/copilot-indexing-issues-prs"],
integrity_tags: ["none:github/copilot-indexing-issues-prs"],
author_association: "NONE",
author_login: "external-user",
html_url: "https://github.com/github/copilot-indexing-issues-prs/issues/42",
number: "42",
}),
JSON.stringify({ timestamp: "2026-03-18T17:30:01Z", type: "RESPONSE", server_id: "github" }),
JSON.stringify({
timestamp: "2026-03-18T17:31:00Z",
type: "DIFC_FILTERED",
server_id: "github",
tool_name: "get_issue",
reason: "Secrecy check failed",
author_login: "user2",
}),
].join("\n");

const events = parseGatewayJsonlForDifcFiltered(jsonlContent);

expect(events).toHaveLength(2);
expect(events[0].tool_name).toBe("list_issues");
expect(events[0].server_id).toBe("github");
expect(events[0].author_login).toBe("external-user");
expect(events[1].tool_name).toBe("get_issue");
});

test("returns empty array when no DIFC_FILTERED events", () => {
const jsonlContent = [JSON.stringify({ timestamp: "2026-03-18T17:30:01Z", type: "RESPONSE", server_id: "github" }), JSON.stringify({ timestamp: "2026-03-18T17:30:02Z", type: "REQUEST", server_id: "github" })].join("\n");

const events = parseGatewayJsonlForDifcFiltered(jsonlContent);
expect(events).toHaveLength(0);
});

test("returns empty array for empty content", () => {
expect(parseGatewayJsonlForDifcFiltered("")).toHaveLength(0);
});

test("skips malformed JSON lines", () => {
const jsonlContent = ["not valid json", JSON.stringify({ type: "DIFC_FILTERED", tool_name: "valid_tool" }), "{broken}"].join("\n");

const events = parseGatewayJsonlForDifcFiltered(jsonlContent);
expect(events).toHaveLength(1);
expect(events[0].tool_name).toBe("valid_tool");
});

test("skips blank lines", () => {
const jsonlContent = "\n" + JSON.stringify({ type: "DIFC_FILTERED", tool_name: "t1" }) + "\n\n" + JSON.stringify({ type: "DIFC_FILTERED", tool_name: "t2" }) + "\n";

const events = parseGatewayJsonlForDifcFiltered(jsonlContent);
expect(events).toHaveLength(2);
});
});

describe("generateDifcFilteredSummary", () => {
const sampleEvents = [
{
timestamp: "2026-03-18T17:30:00.123456789Z",
type: "DIFC_FILTERED",
server_id: "github",
tool_name: "list_issues",
description: "resource:list_issues",
reason: "Integrity check failed, missingTags=[approved:github/copilot-indexing-issues-prs]",
secrecy_tags: ["private:github/copilot-indexing-issues-prs"],
integrity_tags: ["none:github/copilot-indexing-issues-prs"],
author_association: "NONE",
author_login: "external-user",
html_url: "https://github.com/github/copilot-indexing-issues-prs/issues/42",
number: "42",
},
];

test("returns empty string for empty events array", () => {
expect(generateDifcFilteredSummary([])).toBe("");
});

test("returns empty string for null/undefined", () => {
expect(generateDifcFilteredSummary(null)).toBe("");
expect(generateDifcFilteredSummary(undefined)).toBe("");
});

test("generates details/summary section with event count", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("<details>");
expect(summary).toContain("DIFC Filtered Events (1)");
expect(summary).toContain("</details>");
});

test("includes tool name in code formatting", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("`list_issues`");
});

test("includes server_id", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("github");
});

test("includes reason for filtering", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("Integrity check failed");
});

test("includes author login and association", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("external-user");
expect(summary).toContain("NONE");
});

test("renders resource as linked issue number", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("[#42]");
expect(summary).toContain("https://github.com/github/copilot-indexing-issues-prs/issues/42");
});

test("uses description as resource when html_url absent", () => {
const events = [{ type: "DIFC_FILTERED", tool_name: "my_tool", description: "resource:my_tool" }];
const summary = generateDifcFilteredSummary(events);
expect(summary).toContain("resource:my_tool");
});

test("escapes pipe characters in reason", () => {
const events = [{ type: "DIFC_FILTERED", tool_name: "t", reason: "failed | check" }];
const summary = generateDifcFilteredSummary(events);
expect(summary).toContain("failed \\| check");
});

test("generates correct table header", () => {
const summary = generateDifcFilteredSummary(sampleEvents);
expect(summary).toContain("| Time | Server | Tool | Reason | User | Resource |");
expect(summary).toContain("|------|--------|------|--------|------|----------|");
});

test("shows event count in summary for multiple events", () => {
const multiEvents = [
{ type: "DIFC_FILTERED", tool_name: "t1", reason: "r1" },
{ type: "DIFC_FILTERED", tool_name: "t2", reason: "r2" },
{ type: "DIFC_FILTERED", tool_name: "t3", reason: "r3" },
];
const summary = generateDifcFilteredSummary(multiEvents);
expect(summary).toContain("DIFC Filtered Events (3)");
});
});
});
7 changes: 4 additions & 3 deletions pkg/cli/audit_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ type ToolUsageInfo struct {

// MCPToolUsageData contains detailed MCP tool usage statistics and individual call records
type MCPToolUsageData struct {
Summary []MCPToolSummary `json:"summary"` // Aggregated statistics per tool
ToolCalls []MCPToolCall `json:"tool_calls"` // Individual tool call records
Servers []MCPServerStats `json:"servers,omitempty"` // Server-level statistics
Summary []MCPToolSummary `json:"summary"` // Aggregated statistics per tool
ToolCalls []MCPToolCall `json:"tool_calls"` // Individual tool call records
Servers []MCPServerStats `json:"servers,omitempty"` // Server-level statistics
FilteredEvents []DifcFilteredEvent `json:"filtered_events,omitempty"` // DIFC filtered events
}

// MCPToolSummary contains aggregated statistics for a single MCP tool
Expand Down
Loading
Loading