From 3b3ad166eb93891d99298df9ef7a56b3be291ae8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:44:24 +0000 Subject: [PATCH 1/5] Initial plan From e668d115e048108d81ddf4a8fcffd11b3bc3a023 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:55:09 +0000 Subject: [PATCH 2/5] Initial plan Agent-Logs-Url: https://github.com/github/gh-aw/sessions/addb4f0d-a52f-42ea-acbc-180202df9f66 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/slide-deck-maintainer.lock.yml | 2 +- .github/workflows/stale-repo-identifier.lock.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 4c321d3dc09..7f6edc70598 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -885,7 +885,7 @@ jobs: issues: write pull-requests: write concurrency: - group: "gh-aw-conclusion-slide-deck-maintainer" + group: "gh-aw-conclusion-slide-deck-maintainer-${{ inputs.focus || github.run_id }}" cancel-in-progress: false outputs: noop_message: ${{ steps.noop.outputs.noop_message }} diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index d12bee33b44..c2ac4beab7d 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -899,7 +899,7 @@ jobs: contents: write issues: write concurrency: - group: "gh-aw-conclusion-stale-repo-identifier" + group: "gh-aw-conclusion-stale-repo-identifier-${{ inputs.organization || github.run_id }}" cancel-in-progress: false outputs: noop_message: ${{ steps.noop.outputs.noop_message }} From 131091f08bcd4d1f15b4214a78ba815ecf7bccc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:07:09 +0000 Subject: [PATCH 3/5] Fix lock file integrity check for cross-org reusable workflows with local filesystem fallback Agent-Logs-Url: https://github.com/github/gh-aw/sessions/addb4f0d-a52f-42ea-acbc-180202df9f66 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/check_workflow_timestamp_api.cjs | 76 +++++++++- .../js/check_workflow_timestamp_api.test.cjs | 132 +++++++++++++++++- 2 files changed, 201 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index 825e9e4bbfd..ea67bbf6777 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -6,8 +6,15 @@ * This script verifies that the stored frontmatter hash in the lock file * matches the recomputed hash from the source .md file, regardless of * commit timestamps. + * + * Supports both same-repo and cross-repo reusable workflow scenarios: + * - Primary: GitHub API (uses GITHUB_WORKFLOW_REF to identify source repo) + * - Fallback: local filesystem ($GITHUB_WORKSPACE) when API access is unavailable + * (e.g., cross-org reusable workflows where the caller token can't read the source repo) */ +const fs = require("fs"); +const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { extractHashFromLockFile, computeFrontmatterHash, createGitHubFileReader } = require("./frontmatter_hash_pure.cjs"); const { getFileContent } = require("./github_api_helpers.cjs"); @@ -73,15 +80,70 @@ async function main() { core.info(`Same-repo invocation: checking out ${workflowRepo} @ ${ref}`); } - // Helper function to compute and compare frontmatter hashes - // Returns: { match: boolean, storedHash: string, recomputedHash: string } or null on error + // Fallback: compare frontmatter hashes using local filesystem files. + // Used when the GitHub API is inaccessible (e.g., cross-org reusable workflow where + // the caller's GITHUB_TOKEN cannot read the source repo). + // The activation job's "Checkout .github and .agents folders" step always runs before + // this check and places the workflow source files in $GITHUB_WORKSPACE, so the local + // files are always available at this point. + async function compareFrontmatterHashesFromLocalFiles() { + const workspace = process.env.GITHUB_WORKSPACE; + if (!workspace) { + core.info("GITHUB_WORKSPACE not available for local filesystem fallback"); + return null; + } + + const localLockFilePath = path.join(workspace, lockFilePath); + const localMdFilePath = path.join(workspace, workflowMdPath); + + core.info(`Attempting local filesystem fallback for hash comparison:`); + core.info(` Lock file: ${localLockFilePath}`); + core.info(` Source: ${localMdFilePath}`); + + if (!fs.existsSync(localLockFilePath)) { + core.info(`Local lock file not found: ${localLockFilePath}`); + return null; + } + + if (!fs.existsSync(localMdFilePath)) { + core.info(`Local source file not found: ${localMdFilePath}`); + return null; + } + + try { + const localLockContent = fs.readFileSync(localLockFilePath, "utf8"); + const storedHash = extractHashFromLockFile(localLockContent); + if (!storedHash) { + core.info("No frontmatter hash found in local lock file"); + return null; + } + + // computeFrontmatterHash uses the local filesystem reader by default + const recomputedHash = await computeFrontmatterHash(localMdFilePath); + + const match = storedHash === recomputedHash; + + core.info(`Frontmatter hash comparison (local filesystem fallback):`); + core.info(` Lock file hash: ${storedHash}`); + core.info(` Recomputed hash: ${recomputedHash}`); + core.info(` Status: ${match ? "✅ Hashes match" : "⚠️ Hashes differ"}`); + + return { match, storedHash, recomputedHash }; + } catch (error) { + core.info(`Could not compute frontmatter hash from local files: ${getErrorMessage(error)}`); + return null; + } + } + + // Primary: compare frontmatter hashes using the GitHub API. + // Falls back to local filesystem if the API is inaccessible. async function compareFrontmatterHashes() { try { // Fetch lock file content to extract stored hash const lockFileContent = await getFileContent(github, owner, repo, lockFilePath, ref); if (!lockFileContent) { - core.info("Unable to fetch lock file content for hash comparison"); - return null; + core.info("Unable to fetch lock file content for hash comparison via API, trying local filesystem fallback"); + return await compareFrontmatterHashesFromLocalFiles(); } const storedHash = extractHashFromLockFile(lockFileContent); @@ -106,8 +168,10 @@ async function main() { return { match, storedHash, recomputedHash }; } catch (error) { const errorMessage = getErrorMessage(error); - core.info(`Could not compute frontmatter hash: ${errorMessage}`); - return null; + core.info(`Could not compute frontmatter hash via API: ${errorMessage}`); + // Fall back to local filesystem when API is unavailable + // (e.g., cross-org reusable workflow where caller token lacks source repo access) + return await compareFrontmatterHashesFromLocalFiles(); } } diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 1906e78a8ed..9173f1a1ea2 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; const mockCore = { debug: vi.fn(), @@ -48,6 +51,7 @@ describe("check_workflow_timestamp_api.cjs", () => { delete process.env.GH_AW_WORKFLOW_FILE; delete process.env.GITHUB_WORKFLOW_REF; delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_WORKSPACE; // Dynamically import the module to get fresh instance const module = await import("./check_workflow_timestamp_api.cjs"); @@ -622,4 +626,130 @@ engine: copilot expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("(default branch)")); }); }); + + describe("local filesystem fallback for cross-org reusable workflows", () => { + let tmpDir; + + beforeEach(async () => { + process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; + // Simulate cross-org: workflow defined in source-org/source-repo, running in target-org/target-repo + process.env.GITHUB_WORKFLOW_REF = "source-org/source-repo/.github/workflows/test.lock.yml@v1"; + process.env.GITHUB_REPOSITORY = "target-org/target-repo"; + + // Create temp directory structure mimicking $GITHUB_WORKSPACE after checkout + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); + fs.mkdirSync(path.join(tmpDir, ".github", "workflows"), { recursive: true }); + + const module = await import("./check_workflow_timestamp_api.cjs"); + main = module.main; + }); + + afterEach(() => { + delete process.env.GITHUB_WORKSPACE; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("should pass when API fails but local files have matching hashes", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + + const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const workflowsDir = path.join(tmpDir, ".github", "workflows"); + + // Write local files — hash for "engine: copilot" frontmatter + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${validHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: copilot\n---\n# Test"); + + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fail when API fails and local files have mismatched hashes", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + + // Hash for "engine: copilot" frontmatter + const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const workflowsDir = path.join(tmpDir, ".github", "workflows"); + + // Lock file stores copilot hash but .md file now has claude frontmatter + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${storedHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Test"); + + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("outdated")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("frontmatter has changed")); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should fail when both API and local filesystem are unavailable", async () => { + // Simulate cross-org API permission error + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + // Do not set GITHUB_WORKSPACE — local filesystem fallback also unavailable + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Unable to fetch lock file content for hash comparison via API")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKSPACE not available")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); + + it("should fail when API fails and local lock file is missing", async () => { + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + // Workspace exists but lock file not present + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Local lock file not found")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); + + it("should use API if available even in cross-repo scenario (API preferred over local files)", async () => { + const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; + const lockFileContent = `# frontmatter-hash: ${validHash}\nname: Test\n`; + const mdFileContent = "---\nengine: copilot\n---\n# Test"; + + // API succeeds + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(mdFileContent).toString("base64"), + }, + }); + + // Local files also available (but should not be used since API succeeds) + const workflowsDir = path.join(tmpDir, ".github", "workflows"); + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), "# frontmatter-hash: different-hash\nname: Test\n"); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Different"); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + // API result takes precedence (hashes match via API) + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + }); }); From 7a8651c753a75775f6e7eca6dd31077fc4e3e18a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:12:14 +0000 Subject: [PATCH 4/5] Address code review: extract hash constant and workflowsDir into beforeEach Agent-Logs-Url: https://github.com/github/gh-aw/sessions/addb4f0d-a52f-42ea-acbc-180202df9f66 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/check_workflow_timestamp_api.test.cjs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 9173f1a1ea2..716ac4a67bf 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -629,6 +629,9 @@ engine: copilot describe("local filesystem fallback for cross-org reusable workflows", () => { let tmpDir; + let workflowsDir; + // Pre-computed hash for frontmatter "engine: copilot" (used across multiple tests) + const copilotFrontmatterHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; beforeEach(async () => { process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml"; @@ -638,7 +641,8 @@ engine: copilot // Create temp directory structure mimicking $GITHUB_WORKSPACE after checkout tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); - fs.mkdirSync(path.join(tmpDir, ".github", "workflows"), { recursive: true }); + workflowsDir = path.join(tmpDir, ".github", "workflows"); + fs.mkdirSync(workflowsDir, { recursive: true }); const module = await import("./check_workflow_timestamp_api.cjs"); main = module.main; @@ -653,11 +657,8 @@ engine: copilot // Simulate cross-org API permission error mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); - const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; - const workflowsDir = path.join(tmpDir, ".github", "workflows"); - - // Write local files — hash for "engine: copilot" frontmatter - fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${validHash}\nname: Test\n`); + // Write local files — hash matches "engine: copilot" frontmatter + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: copilot\n---\n# Test"); process.env.GITHUB_WORKSPACE = tmpDir; @@ -673,12 +674,8 @@ engine: copilot // Simulate cross-org API permission error mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); - // Hash for "engine: copilot" frontmatter - const storedHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; - const workflowsDir = path.join(tmpDir, ".github", "workflows"); - // Lock file stores copilot hash but .md file now has claude frontmatter - fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${storedHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Test"); process.env.GITHUB_WORKSPACE = tmpDir; @@ -718,8 +715,7 @@ engine: copilot }); it("should use API if available even in cross-repo scenario (API preferred over local files)", async () => { - const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; - const lockFileContent = `# frontmatter-hash: ${validHash}\nname: Test\n`; + const lockFileContent = `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`; const mdFileContent = "---\nengine: copilot\n---\n# Test"; // API succeeds @@ -740,7 +736,6 @@ engine: copilot }); // Local files also available (but should not be used since API succeeds) - const workflowsDir = path.join(tmpDir, ".github", "workflows"); fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), "# frontmatter-hash: different-hash\nname: Test\n"); fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: claude\n---\n# Different"); process.env.GITHUB_WORKSPACE = tmpDir; From 47d01c51663a8dcb3ac2b33b2c6420201fdfa8cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:34:33 +0000 Subject: [PATCH 5/5] Address review: path traversal protection, catch-block test, revert unrelated lock file changes Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2ad785e0-2404-49ac-9023-13704d9978d2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/slide-deck-maintainer.lock.yml | 2 +- .../workflows/stale-repo-identifier.lock.yml | 2 +- .../setup/js/check_workflow_timestamp_api.cjs | 17 +++++++- .../js/check_workflow_timestamp_api.test.cjs | 40 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 7f6edc70598..4c321d3dc09 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -885,7 +885,7 @@ jobs: issues: write pull-requests: write concurrency: - group: "gh-aw-conclusion-slide-deck-maintainer-${{ inputs.focus || github.run_id }}" + group: "gh-aw-conclusion-slide-deck-maintainer" cancel-in-progress: false outputs: noop_message: ${{ steps.noop.outputs.noop_message }} diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index c2ac4beab7d..d12bee33b44 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -899,7 +899,7 @@ jobs: contents: write issues: write concurrency: - group: "gh-aw-conclusion-stale-repo-identifier-${{ inputs.organization || github.run_id }}" + group: "gh-aw-conclusion-stale-repo-identifier" cancel-in-progress: false outputs: noop_message: ${{ steps.noop.outputs.noop_message }} diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index ea67bbf6777..cdc538a6faa 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -93,8 +93,21 @@ async function main() { return null; } - const localLockFilePath = path.join(workspace, lockFilePath); - const localMdFilePath = path.join(workspace, workflowMdPath); + // Resolve and validate both paths to prevent path traversal attacks. + // GH_AW_WORKFLOW_FILE could theoretically contain "../" segments; reject any + // resolved path that escapes the workspace/.github/workflows directory. + const allowedDir = path.resolve(workspace, ".github", "workflows"); + const localLockFilePath = path.resolve(workspace, lockFilePath); + const localMdFilePath = path.resolve(workspace, workflowMdPath); + + if (!localLockFilePath.startsWith(allowedDir + path.sep) && localLockFilePath !== allowedDir) { + core.info(`Resolved lock file path escapes workspace: ${localLockFilePath}`); + return null; + } + if (!localMdFilePath.startsWith(allowedDir + path.sep) && localMdFilePath !== allowedDir) { + core.info(`Resolved source file path escapes workspace: ${localMdFilePath}`); + return null; + } core.info(`Attempting local filesystem fallback for hash comparison:`); core.info(` Lock file: ${localLockFilePath}`); diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 716ac4a67bf..7a4d227e0a4 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -746,5 +746,45 @@ engine: copilot expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); + + it("should fall back to local files when API lock file fetch succeeds but md file fetch throws", async () => { + // First API call (lock file) succeeds, second (md file) throws — triggers the catch-block fallback + const lockFileContent = `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`; + mockGithub.rest.repos.getContent + .mockResolvedValueOnce({ + data: { + type: "file", + encoding: "base64", + content: Buffer.from(lockFileContent).toString("base64"), + }, + }) + .mockRejectedValueOnce(new Error("Resource not accessible by integration")); + + // Local files have matching hashes + fs.writeFileSync(path.join(workflowsDir, "test.lock.yml"), `# frontmatter-hash: ${copilotFrontmatterHash}\nname: Test\n`); + fs.writeFileSync(path.join(workflowsDir, "test.md"), "---\nengine: copilot\n---\n# Test"); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not compute frontmatter hash via API")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("local filesystem fallback")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date (hashes match)")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should reject path traversal in GH_AW_WORKFLOW_FILE via local filesystem fallback", async () => { + // Craft a malicious workflow file name that tries to escape the workspace + process.env.GH_AW_WORKFLOW_FILE = "../../etc/passwd.lock.yml"; + mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration")); + process.env.GITHUB_WORKSPACE = tmpDir; + + await main(); + + // The path traversal is rejected before any file read + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("escapes workspace")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + }); }); });