diff --git a/.changeset/patch-github-guard-difc-footer-notice.md b/.changeset/patch-github-guard-difc-footer-notice.md new file mode 100644 index 0000000000..8b0cc9e2ee --- /dev/null +++ b/.changeset/patch-github-guard-difc-footer-notice.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add a footer tip notice when GitHub Guard filters DIFC items during workflow execution, including linked GitHub references and filter reasons. diff --git a/actions/setup/js/gateway_difc_filtered.cjs b/actions/setup/js/gateway_difc_filtered.cjs new file mode 100644 index 0000000000..a55f571376 --- /dev/null +++ b/actions/setup/js/gateway_difc_filtered.cjs @@ -0,0 +1,136 @@ +// @ts-check +/// + +/** + * Gateway DIFC Filtered Module + * + * This module handles reading MCP gateway logs and extracting DIFC_FILTERED events + * for display in AI-generated footers. + */ + +const fs = require("fs"); + +const GATEWAY_JSONL_PATH = "/tmp/gh-aw/mcp-logs/gateway.jsonl"; +const RPC_MESSAGES_PATH = "/tmp/gh-aw/mcp-logs/rpc-messages.jsonl"; + +/** + * Parses JSONL content and extracts DIFC_FILTERED events + * @param {string} jsonlContent - The JSONL file content + * @returns {Array} Array of DIFC_FILTERED event objects + */ +function parseDifcFilteredEvents(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); + if (entry.type === "DIFC_FILTERED") { + filteredEvents.push(entry); + } + } catch { + // skip malformed lines + } + } + return filteredEvents; +} + +/** + * Reads DIFC_FILTERED events from MCP gateway logs. + * + * This function checks two possible locations for gateway logs: + * 1. Path specified by gatewayJsonlPath (or /tmp/gh-aw/mcp-logs/gateway.jsonl by default) + * 2. Path specified by rpcMessagesPath (or /tmp/gh-aw/mcp-logs/rpc-messages.jsonl as fallback) + * + * @param {string} [gatewayJsonlPath] - Path to gateway.jsonl. Defaults to /tmp/gh-aw/mcp-logs/gateway.jsonl + * @param {string} [rpcMessagesPath] - Path to rpc-messages.jsonl fallback. Defaults to /tmp/gh-aw/mcp-logs/rpc-messages.jsonl + * @returns {Array} Array of DIFC_FILTERED event objects + */ +function getDifcFilteredEvents(gatewayJsonlPath, rpcMessagesPath) { + const jsonlPath = gatewayJsonlPath || GATEWAY_JSONL_PATH; + const rpcPath = rpcMessagesPath || RPC_MESSAGES_PATH; + + if (fs.existsSync(jsonlPath)) { + try { + const content = fs.readFileSync(jsonlPath, "utf8"); + return parseDifcFilteredEvents(content); + } catch { + return []; + } + } + + if (fs.existsSync(rpcPath)) { + try { + const content = fs.readFileSync(rpcPath, "utf8"); + return parseDifcFilteredEvents(content); + } catch { + return []; + } + } + + return []; +} + +/** + * Generates HTML details/summary section for integrity-filtered items wrapped in a GitHub note alert. + * @param {Array} filteredEvents - Array of DIFC_FILTERED event objects + * @returns {string} GitHub note alert with details section, or empty string if no filtered events + */ +function generateDifcFilteredSection(filteredEvents) { + if (!filteredEvents || filteredEvents.length === 0) { + return ""; + } + + // Deduplicate events by their significant fields + const seen = new Set(); + const uniqueEvents = filteredEvents.filter(event => { + const key = [event.html_url || "", event.tool_name || "", event.description || "", event.reason || ""].join("|"); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const count = uniqueEvents.length; + const itemWord = count === 1 ? "item" : "items"; + + let section = "\n\n> [!NOTE]\n"; + section += `>
\n`; + section += `> 🔒 Integrity filtering filtered ${count} ${itemWord}\n`; + section += `>\n`; + section += `> Integrity filtering activated and filtered the following ${itemWord} during workflow execution.\n`; + section += `> This happens when a tool call accesses a resource that does not meet the required integrity or secrecy level of the workflow.\n`; + section += `>\n`; + + const maxItems = 16; + const visibleEvents = uniqueEvents.slice(0, maxItems); + const remainingCount = uniqueEvents.length - visibleEvents.length; + + for (const event of visibleEvents) { + let reference; + if (event.html_url) { + const label = event.number ? `#${event.number}` : event.html_url; + reference = `[${label}](${event.html_url})`; + } else { + reference = event.description || (event.tool_name ? `\`${event.tool_name}\`` : "-"); + } + const tool = event.tool_name ? `\`${event.tool_name}\`` : "-"; + const reason = (event.reason || "-").replace(/\n/g, " "); + section += `> - ${reference} (${tool}: ${reason})\n`; + } + + if (remainingCount > 0) { + section += `> - ... and ${remainingCount} more ${remainingCount === 1 ? "item" : "items"}\n`; + } + + section += `>\n`; + section += `>
\n`; + + return section; +} + +module.exports = { + parseDifcFilteredEvents, + getDifcFilteredEvents, + generateDifcFilteredSection, +}; diff --git a/actions/setup/js/gateway_difc_filtered.test.cjs b/actions/setup/js/gateway_difc_filtered.test.cjs new file mode 100644 index 0000000000..1b36430eaa --- /dev/null +++ b/actions/setup/js/gateway_difc_filtered.test.cjs @@ -0,0 +1,339 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +describe("gateway_difc_filtered.cjs", () => { + let parseDifcFilteredEvents; + let getDifcFilteredEvents; + let generateDifcFilteredSection; + let testDir; + + beforeEach(async () => { + // Create a temporary directory for test files + testDir = path.join(os.tmpdir(), `gh-aw-test-difc-${Date.now()}`); + fs.mkdirSync(testDir, { recursive: true }); + + // Dynamic import to get fresh module state + const module = await import("./gateway_difc_filtered.cjs"); + parseDifcFilteredEvents = module.parseDifcFilteredEvents; + getDifcFilteredEvents = module.getDifcFilteredEvents; + generateDifcFilteredSection = module.generateDifcFilteredSection; + }); + + afterEach(() => { + // Clean up test directory + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe("parseDifcFilteredEvents", () => { + it("should return empty array for empty content", () => { + expect(parseDifcFilteredEvents("")).toEqual([]); + expect(parseDifcFilteredEvents("\n\n")).toEqual([]); + }); + + it("should extract DIFC_FILTERED events from JSONL content", () => { + const content = [ + JSON.stringify({ + timestamp: "2026-03-18T17:30:00Z", + type: "DIFC_FILTERED", + server_id: "github", + tool_name: "list_issues", + reason: "Integrity check failed", + html_url: "https://github.com/org/repo/issues/42", + number: "42", + }), + JSON.stringify({ timestamp: "2026-03-18T17:30:01Z", type: "RESPONSE", server_id: "github" }), + ].join("\n"); + + const events = parseDifcFilteredEvents(content); + expect(events).toHaveLength(1); + expect(events[0].tool_name).toBe("list_issues"); + expect(events[0].number).toBe("42"); + }); + + it("should extract multiple DIFC_FILTERED events", () => { + const content = [JSON.stringify({ type: "DIFC_FILTERED", tool_name: "tool1", reason: "r1" }), JSON.stringify({ type: "DIFC_FILTERED", tool_name: "tool2", reason: "r2" }), JSON.stringify({ type: "REQUEST", tool_name: "tool3" })].join( + "\n" + ); + + const events = parseDifcFilteredEvents(content); + expect(events).toHaveLength(2); + expect(events[0].tool_name).toBe("tool1"); + expect(events[1].tool_name).toBe("tool2"); + }); + + it("should skip malformed JSON lines", () => { + const content = ["{ not valid json", JSON.stringify({ type: "DIFC_FILTERED", tool_name: "valid_tool" }), "another bad line"].join("\n"); + + const events = parseDifcFilteredEvents(content); + expect(events).toHaveLength(1); + expect(events[0].tool_name).toBe("valid_tool"); + }); + + it("should skip blank lines", () => { + const content = "\n" + JSON.stringify({ type: "DIFC_FILTERED", tool_name: "t1" }) + "\n\n" + JSON.stringify({ type: "DIFC_FILTERED", tool_name: "t2" }) + "\n"; + + const events = parseDifcFilteredEvents(content); + expect(events).toHaveLength(2); + }); + + it("should ignore lines without DIFC_FILTERED string for efficiency", () => { + const content = [JSON.stringify({ type: "REQUEST", tool_name: "not_filtered" }), JSON.stringify({ type: "RESPONSE", result: "ok" })].join("\n"); + + const events = parseDifcFilteredEvents(content); + expect(events).toHaveLength(0); + }); + }); + + describe("getDifcFilteredEvents", () => { + it("should return empty array when neither log file exists", () => { + const nonExistent1 = path.join(testDir, "nonexistent1.jsonl"); + const nonExistent2 = path.join(testDir, "nonexistent2.jsonl"); + + const events = getDifcFilteredEvents(nonExistent1, nonExistent2); + expect(events).toEqual([]); + }); + + it("should read events from primary gateway.jsonl path", () => { + const jsonlPath = path.join(testDir, "gateway.jsonl"); + const content = JSON.stringify({ type: "DIFC_FILTERED", tool_name: "list_issues", reason: "test" }); + fs.writeFileSync(jsonlPath, content); + + const events = getDifcFilteredEvents(jsonlPath, path.join(testDir, "rpc.jsonl")); + expect(events).toHaveLength(1); + expect(events[0].tool_name).toBe("list_issues"); + }); + + it("should fall back to rpc-messages.jsonl when gateway.jsonl does not exist", () => { + const rpcPath = path.join(testDir, "rpc-messages.jsonl"); + const content = JSON.stringify({ type: "DIFC_FILTERED", tool_name: "get_issue", reason: "secrecy" }); + fs.writeFileSync(rpcPath, content); + + const events = getDifcFilteredEvents(path.join(testDir, "nonexistent.jsonl"), rpcPath); + expect(events).toHaveLength(1); + expect(events[0].tool_name).toBe("get_issue"); + }); + + it("should prefer primary path over fallback when both exist", () => { + const jsonlPath = path.join(testDir, "gateway.jsonl"); + const rpcPath = path.join(testDir, "rpc-messages.jsonl"); + fs.writeFileSync(jsonlPath, JSON.stringify({ type: "DIFC_FILTERED", tool_name: "primary_tool" })); + fs.writeFileSync(rpcPath, JSON.stringify({ type: "DIFC_FILTERED", tool_name: "fallback_tool" })); + + const events = getDifcFilteredEvents(jsonlPath, rpcPath); + expect(events).toHaveLength(1); + expect(events[0].tool_name).toBe("primary_tool"); + }); + + it("should return empty array when log file is empty", () => { + const jsonlPath = path.join(testDir, "gateway.jsonl"); + fs.writeFileSync(jsonlPath, ""); + + const events = getDifcFilteredEvents(jsonlPath, path.join(testDir, "rpc.jsonl")); + expect(events).toEqual([]); + }); + }); + + describe("generateDifcFilteredSection", () => { + it("should return empty string when no filtered events", () => { + expect(generateDifcFilteredSection([])).toBe(""); + expect(generateDifcFilteredSection(null)).toBe(""); + expect(generateDifcFilteredSection(undefined)).toBe(""); + }); + + it("should generate tip alert section for single filtered item", () => { + const events = [ + { + type: "DIFC_FILTERED", + tool_name: "list_issues", + reason: "Integrity check failed", + html_url: "https://github.com/org/repo/issues/42", + number: "42", + }, + ]; + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("> [!NOTE]"); + expect(result).toContain(">
"); + expect(result).toContain(">
"); + expect(result).toContain("> 🔒 Integrity filtering filtered 1 item"); + expect(result).toContain("[#42](https://github.com/org/repo/issues/42)"); + expect(result).toContain("`list_issues`"); + expect(result).toContain("Integrity check failed"); + }); + + it("should generate tip alert section for multiple filtered items", () => { + const events = [ + { + type: "DIFC_FILTERED", + tool_name: "list_issues", + reason: "Integrity check failed", + html_url: "https://github.com/org/repo/issues/42", + number: "42", + }, + { + type: "DIFC_FILTERED", + tool_name: "get_issue", + reason: "Secrecy check failed", + html_url: "https://github.com/org/repo/issues/99", + number: "99", + }, + ]; + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("> [!NOTE]"); + expect(result).toContain("> 🔒 Integrity filtering filtered 2 items"); + expect(result).toContain("[#42](https://github.com/org/repo/issues/42)"); + expect(result).toContain("[#99](https://github.com/org/repo/issues/99)"); + }); + + it("should use description as reference when html_url is absent", () => { + const events = [ + { + type: "DIFC_FILTERED", + tool_name: "list_issues", + description: "resource:list_issues", + reason: "Integrity check failed", + }, + ]; + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("resource:list_issues"); + expect(result).not.toContain("[#"); + }); + + it("should use tool_name as reference when html_url and description are absent", () => { + const events = [ + { + type: "DIFC_FILTERED", + tool_name: "my_tool", + reason: "check failed", + }, + ]; + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("`my_tool`"); + }); + + it("should use html_url directly as label when number is absent", () => { + const events = [ + { + type: "DIFC_FILTERED", + tool_name: "list_repos", + reason: "Integrity check failed", + html_url: "https://github.com/org/repo", + }, + ]; + + const result = generateDifcFilteredSection(events); + + // html_url used as label when no number + expect(result).toContain("[https://github.com/org/repo](https://github.com/org/repo)"); + }); + + it("should include explanation text about why filtering happened", () => { + const events = [{ type: "DIFC_FILTERED", tool_name: "tool", reason: "reason" }]; + const result = generateDifcFilteredSection(events); + + expect(result).toContain("Integrity filtering activated"); + expect(result).toContain("integrity or secrecy level"); + }); + + it("should start with double newline and note alert", () => { + const events = [{ type: "DIFC_FILTERED", tool_name: "tool", reason: "reason" }]; + const result = generateDifcFilteredSection(events); + + expect(result).toMatch(/^\n\n> \[!NOTE\]/); + }); + + it("should use correct singular/plural form", () => { + const singleEvent = [{ type: "DIFC_FILTERED", tool_name: "tool", reason: "reason" }]; + const singleResult = generateDifcFilteredSection(singleEvent); + expect(singleResult).toContain("1 item"); + expect(singleResult).not.toContain("items"); + + const multiEvents = [ + { type: "DIFC_FILTERED", tool_name: "tool1", reason: "r1" }, + { type: "DIFC_FILTERED", tool_name: "tool2", reason: "r2" }, + ]; + const multiResult = generateDifcFilteredSection(multiEvents); + expect(multiResult).toContain("2 items"); + }); + + it("should deduplicate filtered events with identical fields", () => { + const events = [ + { type: "DIFC_FILTERED", tool_name: "list_issues", reason: "Integrity check failed", html_url: "https://github.com/org/repo/issues/42", number: "42" }, + { type: "DIFC_FILTERED", tool_name: "list_issues", reason: "Integrity check failed", html_url: "https://github.com/org/repo/issues/42", number: "42" }, + { type: "DIFC_FILTERED", tool_name: "get_issue", reason: "Secrecy check failed", html_url: "https://github.com/org/repo/issues/99", number: "99" }, + ]; + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("> 🔒 Integrity filtering filtered 2 items"); + expect(result).toContain("[#42](https://github.com/org/repo/issues/42)"); + expect(result).toContain("[#99](https://github.com/org/repo/issues/99)"); + }); + + it("should replace newlines in reason with spaces", () => { + const events = [{ type: "DIFC_FILTERED", tool_name: "tool", reason: "line1\nline2" }]; + const result = generateDifcFilteredSection(events); + + expect(result).toContain("line1 line2"); + expect(result).not.toContain("line1\nline2"); + }); + + it("should show at most 16 items and ellipse the rest", () => { + const events = Array.from({ length: 20 }, (_, i) => ({ + type: "DIFC_FILTERED", + tool_name: `tool_${i}`, + reason: "reason", + html_url: `https://github.com/org/repo/issues/${i + 1}`, + number: String(i + 1), + })); + + const result = generateDifcFilteredSection(events); + + // Summary still shows the total count + expect(result).toContain("> 🔒 Integrity filtering filtered 20 items"); + // First 16 items rendered + expect(result).toContain("[#1](https://github.com/org/repo/issues/1)"); + expect(result).toContain("[#16](https://github.com/org/repo/issues/16)"); + // Items 17-20 not rendered individually + expect(result).not.toContain("[#17]"); + // Ellipsis line present + expect(result).toContain("... and 4 more items"); + }); + + it("should not show ellipsis when 16 or fewer items", () => { + const events = Array.from({ length: 16 }, (_, i) => ({ + type: "DIFC_FILTERED", + tool_name: `tool_${i}`, + reason: "reason", + })); + + const result = generateDifcFilteredSection(events); + + expect(result).not.toContain("more item"); + }); + + it("should use singular form in ellipsis for exactly 1 remaining item", () => { + const events = Array.from({ length: 17 }, (_, i) => ({ + type: "DIFC_FILTERED", + tool_name: `tool_${i}`, + reason: "reason", + })); + + const result = generateDifcFilteredSection(events); + + expect(result).toContain("... and 1 more item"); + expect(result).not.toContain("... and 1 more items"); + }); + }); +}); diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 442878c348..2e41960db1 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -11,6 +11,7 @@ const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs"); const { getMissingInfoSections } = require("./missing_messages_helper.cjs"); const { getBlockedDomains, generateBlockedDomainsSection } = require("./firewall_blocked_domains.cjs"); +const { getDifcFilteredEvents, generateDifcFilteredSection } = require("./gateway_difc_filtered.cjs"); /** * @typedef {Object} FooterContext @@ -280,7 +281,29 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl historyUrl: historyUrl || undefined, }; - let footer = "\n\n" + getFooterMessage(ctx); + // Collect guard notices to show BEFORE the attribution footer + let guardNotices = ""; + + // Add firewall blocked domains section if any domains were blocked + const blockedDomains = getBlockedDomains(); + const blockedDomainsSection = generateBlockedDomainsSection(blockedDomains); + if (blockedDomainsSection) { + guardNotices += blockedDomainsSection; + } + + // Add integrity filtering section if any items were filtered + try { + const difcFilteredEvents = getDifcFilteredEvents(); + const difcFilteredSection = generateDifcFilteredSection(difcFilteredEvents); + if (difcFilteredSection) { + guardNotices += difcFilteredSection; + } + } catch { + // ignore errors so the rest of the footer is always preserved + } + + // Attribution footer line comes after any guard notices + let footer = guardNotices + "\n\n" + getFooterMessage(ctx); // Add installation instructions if source is available const installMessage = getFooterInstallMessage(ctx); @@ -294,13 +317,6 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl footer += missingInfoSections; } - // Add firewall blocked domains section if any domains were blocked - const blockedDomains = getBlockedDomains(); - const blockedDomainsSection = generateBlockedDomainsSection(blockedDomains); - if (blockedDomainsSection) { - footer += blockedDomainsSection; - } - // Add XML comment marker for traceability footer += "\n\n" + generateXMLMarker(workflowName, runUrl); diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 6c943eda63..94f5aedeca 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -266,6 +266,7 @@ SAFE_OUTPUTS_FILES=( "handler_auth.cjs" "missing_messages_helper.cjs" "firewall_blocked_domains.cjs" + "gateway_difc_filtered.cjs" "missing_info_formatter.cjs" "sanitize_content_core.cjs" "markdown_code_region_balancer.cjs"