From ab5e139f576f9de2af3cd24c9ef5bbc4669d7447 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:15:01 +0000 Subject: [PATCH 1/7] Initial plan From 7bd1fe81c673cdd074bf33b66252644a57feacca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:23:32 +0000 Subject: [PATCH 2/7] Add cross-repository allowlist validation to handlers - Add validateRepo checks to assign_to_agent.cjs - Add validateRepo checks to create_agent_session.cjs - Add validateRepo checks to get_repository_url.cjs - Add validateRepo checks to push_repo_memory.cjs - Add documentation comments for checkout_pr_branch.cjs (false positive) - Add documentation comments for pr_review_buffer.cjs (validation handled by callers) - Add documentation comments for temporary_id.cjs (utility library) All handlers now implement E004 error code for validation failures. SEC-005 conformance check now passes. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 13 +++++++++++++ actions/setup/js/checkout_pr_branch.cjs | 5 +++++ actions/setup/js/create_agent_session.cjs | 14 ++++++++++++++ actions/setup/js/get_repository_url.cjs | 23 +++++++++++++++++++++++ actions/setup/js/pr_review_buffer.cjs | 4 ++++ actions/setup/js/push_repo_memory.cjs | 12 ++++++++++++ actions/setup/js/temporary_id.cjs | 12 ++++++++++++ 7 files changed, 83 insertions(+) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 0aa01182f9..0e58ab23b4 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -8,6 +8,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { resolveTarget } = require("./safe_output_helpers.cjs"); const { loadTemporaryIdMap, resolveRepoIssueTarget } = require("./temporary_id.cjs"); const { sleep } = require("./error_recovery.cjs"); +const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); async function main() { const result = loadAgentOutput(); @@ -93,9 +94,21 @@ async function main() { let targetOwner = context.repo.owner; let targetRepo = context.repo.repo; + // Get allowed repos configuration for cross-repo validation + const allowedReposEnv = process.env.GH_AW_AGENT_ALLOWED_REPOS?.trim(); + const allowedRepos = parseAllowedRepos(allowedReposEnv); + const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; + if (targetRepoEnv) { const parts = targetRepoEnv.split("/"); if (parts.length === 2) { + // Validate target repository against allowlist + const repoValidation = validateRepo(targetRepoEnv, defaultRepo, allowedRepos); + if (!repoValidation.valid) { + core.setFailed(`E004: ${repoValidation.error}`); + return; + } + targetOwner = parts[0]; targetRepo = parts[1]; core.info(`Using target repository: ${targetOwner}/${targetRepo}`); diff --git a/actions/setup/js/checkout_pr_branch.cjs b/actions/setup/js/checkout_pr_branch.cjs index db5f440433..4fad71ceac 100644 --- a/actions/setup/js/checkout_pr_branch.cjs +++ b/actions/setup/js/checkout_pr_branch.cjs @@ -18,6 +18,11 @@ * 3. Other PR events (issue_comment, pull_request_review, etc.): * - Also run in base repository context * - Must use `gh pr checkout` to get PR branch + * + * NOTE: This handler operates within the PR context from the workflow event + * and does not support cross-repository operations or target-repo parameters. + * No allowlist validation (checkAllowedRepo/validateTargetRepo) is needed as + * it only works with the PR from the triggering event. */ const { getErrorMessage } = require("./error_helpers.cjs"); diff --git a/actions/setup/js/create_agent_session.cjs b/actions/setup/js/create_agent_session.cjs index ba61152f48..1f283ebf7a 100644 --- a/actions/setup/js/create_agent_session.cjs +++ b/actions/setup/js/create_agent_session.cjs @@ -2,6 +2,7 @@ /// const { getErrorMessage } = require("./error_helpers.cjs"); +const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); const fs = require("fs"); const path = require("path"); @@ -81,6 +82,19 @@ async function main() { const baseBranch = process.env.GITHUB_AW_AGENT_SESSION_BASE || process.env.GITHUB_REF_NAME || "main"; const targetRepo = process.env.GITHUB_AW_TARGET_REPO; + // Validate target repository against allowlist if specified + if (targetRepo) { + const allowedReposEnv = process.env.GH_AW_AGENT_SESSION_ALLOWED_REPOS?.trim(); + const allowedRepos = parseAllowedRepos(allowedReposEnv); + const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; + + const repoValidation = validateRepo(targetRepo, defaultRepo, allowedRepos); + if (!repoValidation.valid) { + core.setFailed(`E004: ${repoValidation.error}`); + return; + } + } + // Process all agent session items const createdTasks = []; let summaryContent = "## ✅ Agent Sessions Created\n\n"; diff --git a/actions/setup/js/get_repository_url.cjs b/actions/setup/js/get_repository_url.cjs index e537bd1621..ef8bcceda1 100644 --- a/actions/setup/js/get_repository_url.cjs +++ b/actions/setup/js/get_repository_url.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); + /** * Get the repository URL for different purposes * This helper handles trial mode where target repository URLs are different from execution context @@ -14,9 +16,30 @@ function getRepositoryUrl(config) { // First check if there's a target-repo in config if (config && config["target-repo"]) { targetRepoSlug = config["target-repo"]; + + // Validate target repository against allowlist + const allowedRepos = parseAllowedRepos(config["allowed-repos"] || config.allowed_repos); + const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; + + const repoValidation = validateRepo(targetRepoSlug, defaultRepo, allowedRepos); + if (!repoValidation.valid) { + throw new Error(`E004: ${repoValidation.error}`); + } } else { // Fall back to env var for backward compatibility targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + + if (targetRepoSlug) { + // Validate env var target repository against allowlist + const allowedReposEnv = process.env.GH_AW_TARGET_REPO_ALLOWED_REPOS?.trim(); + const allowedRepos = parseAllowedRepos(allowedReposEnv); + const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; + + const repoValidation = validateRepo(targetRepoSlug, defaultRepo, allowedRepos); + if (!repoValidation.valid) { + throw new Error(`E004: ${repoValidation.error}`); + } + } } if (targetRepoSlug) { diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 46271e0ea4..13aae8fb10 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -7,6 +7,10 @@ * Creates a buffer instance that collects PR review comments and review metadata * so they can be submitted as a single GitHub PR review via pulls.createReview(). * + * Cross-repository validation: The review buffer receives pre-validated repository + * information from handlers like create_pr_review_comment.cjs which use + * validateTargetRepo/checkAllowedRepo before setting the review context. + * * Usage: * const { createReviewBuffer } = require("./pr_review_buffer.cjs"); * const buffer = createReviewBuffer(); diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 61417dd6a6..bbcd717b5b 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -7,6 +7,7 @@ const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { execGitSync } = require("./git_helpers.cjs"); +const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); /** * Push repo-memory changes to git branch @@ -92,6 +93,17 @@ async function main() { return; } + // Validate target repository against allowlist + const allowedReposEnv = process.env.REPO_MEMORY_ALLOWED_REPOS?.trim(); + const allowedRepos = parseAllowedRepos(allowedReposEnv); + const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; + + const repoValidation = validateRepo(targetRepo, defaultRepo, allowedRepos); + if (!repoValidation.valid) { + core.setFailed(`E004: ${repoValidation.error}`); + return; + } + // Source directory with memory files (artifact location) // The artifactDir IS the memory directory (no nested structure needed) const sourceMemoryPath = artifactDir; diff --git a/actions/setup/js/temporary_id.cjs b/actions/setup/js/temporary_id.cjs index 35422cd713..660400bc0d 100644 --- a/actions/setup/js/temporary_id.cjs +++ b/actions/setup/js/temporary_id.cjs @@ -1,6 +1,18 @@ // @ts-check /// +/** + * Temporary ID helper utilities for safe outputs + * + * This module provides helper functions for generating, validating, and resolving + * temporary IDs used to reference not-yet-created resources. + * + * NOTE: This is a utility library that provides helper functions for other handlers. + * It does not perform cross-repository operations directly. Handlers that use these + * utilities (like create_issue, add_comment, etc.) are responsible for validating + * target repositories against their configured allowlists (validateTargetRepo/checkAllowedRepo). + */ + const { getErrorMessage } = require("./error_helpers.cjs"); const crypto = require("crypto"); From 9f2cf85a1dcac78a0c0f5b326144d6d2c3268ad8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:25:56 +0000 Subject: [PATCH 3/7] Add tests for cross-repository allowlist validation - Add test cases for assign_to_agent.cjs allowlist validation - Add test cases for create_agent_session.cjs allowlist validation - Tests cover: rejection of non-allowlisted repos, acceptance of allowlisted repos, and default repo handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.test.cjs | 110 ++++++++++++++++++ .../setup/js/create_agent_session.test.cjs | 54 ++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 8288c340a4..a042880953 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -1033,4 +1033,114 @@ describe("assign_to_agent", () => { const delayMessages = mockCore.info.mock.calls.filter(call => call[0].includes("Waiting 10 seconds before processing next agent assignment")); expect(delayMessages).toHaveLength(2); }, 30000); // Increase timeout to 30 seconds to account for 2x10s delays + + describe("Cross-repository allowlist validation", () => { + it("should reject target repository not in allowlist", async () => { + process.env.GH_AW_TARGET_REPO = "other-owner/other-repo"; + process.env.GH_AW_AGENT_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("E004:")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("not in the allowed-repos list")); + }); + + it("should allow target repository in allowlist", async () => { + process.env.GH_AW_TARGET_REPO = "allowed-owner/allowed-repo"; + process.env.GH_AW_AGENT_ALLOWED_REPOS = "allowed-owner/allowed-repo,other-owner/other-repo"; + + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + .mockResolvedValueOnce({ + repository: { + suggestedActors: { + nodes: [{ login: "copilot-swe-agent", id: "MDQ6VXNlcjE=" }], + }, + }, + }) + .mockResolvedValueOnce({ + repository: { + issue: { id: "issue-id", assignees: { nodes: [] } }, + }, + }) + .mockResolvedValueOnce({ + addAssigneesToAssignable: { + assignable: { assignees: { nodes: [{ login: "copilot-swe-agent" }] } }, + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + // Check that the target repository was used and assignment proceeded + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using target repository: allowed-owner/allowed-repo")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Looking for copilot coding agent")); + }, 20000); + + it("should allow default repository even without allowlist", async () => { + // Default repo is test-owner/test-repo (from mockContext) + process.env.GH_AW_TARGET_REPO = "test-owner/test-repo"; + // Empty or no allowlist + + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + .mockResolvedValueOnce({ + repository: { + suggestedActors: { + nodes: [{ login: "copilot-swe-agent", id: "MDQ6VXNlcjE=" }], + }, + }, + }) + .mockResolvedValueOnce({ + repository: { + issue: { id: "issue-id", assignees: { nodes: [] } }, + }, + }) + .mockResolvedValueOnce({ + addAssigneesToAssignable: { + assignable: { assignees: { nodes: [{ login: "copilot-swe-agent" }] } }, + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + // Check that assignment proceeded without errors + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using target repository: test-owner/test-repo")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Looking for copilot coding agent")); + }, 20000); + }); }); diff --git a/actions/setup/js/create_agent_session.test.cjs b/actions/setup/js/create_agent_session.test.cjs index 370348f04a..8841a60765 100644 --- a/actions/setup/js/create_agent_session.test.cjs +++ b/actions/setup/js/create_agent_session.test.cjs @@ -135,5 +135,57 @@ describe("create_agent_session.cjs", () => { const summaryCall = mockCore.summary.addRaw.mock.calls[0]; expect(summaryCall[0]).toContain("**Base Branch:** main"); })); - })); + })), + describe("Cross-repository allowlist validation", () => { + it("should reject target repository not in allowlist", async () => { + process.env.GITHUB_AW_TARGET_REPO = "other-owner/other-repo"; + process.env.GH_AW_AGENT_SESSION_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + + createAgentOutput([{ type: "create_agent_session", body: "Test task" }]); + + await runScript(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("E004:")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("not in the allowed-repos list")); + }); + + it("should allow target repository in allowlist", async () => { + process.env.GITHUB_AW_TARGET_REPO = "allowed-owner/allowed-repo"; + process.env.GH_AW_AGENT_SESSION_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + + createAgentOutput([{ type: "create_agent_session", body: "Test task" }]); + + // Mock gh CLI command + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/allowed-owner/allowed-repo/issues/123", + stderr: "", + }); + + await runScript(); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", "123"); + }); + + it("should allow default repository without allowlist", async () => { + // No GITHUB_AW_TARGET_REPO set, uses default + delete process.env.GITHUB_AW_TARGET_REPO; + delete process.env.GH_AW_AGENT_SESSION_ALLOWED_REPOS; + + createAgentOutput([{ type: "create_agent_session", body: "Test task" }]); + + // Mock gh CLI command + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/123", + stderr: "", + }); + + await runScript(); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", "123"); + }); + }); }); From 9526b32363d0ff1ac5f05caec4b0f4df48908703 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:28:18 +0000 Subject: [PATCH 4/7] Fix test setup for allowlist validation tests - Add mockContext to create_agent_session tests - Add getExecOutput mock to mockExec - Fix assign_to_agent test to include allowlist for cross-repo test - Clean up test environment variables in afterEach All tests now passing for both handlers. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.test.cjs | 1 + actions/setup/js/create_agent_session.test.cjs | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index a042880953..ec2e7fdd31 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -489,6 +489,7 @@ describe("assign_to_agent", () => { it("should use target repository when configured", async () => { process.env.GH_AW_TARGET_REPO = "other-owner/other-repo"; + process.env.GH_AW_AGENT_ALLOWED_REPOS = "other-owner/other-repo"; // Add to allowlist setAgentOutput({ items: [ { diff --git a/actions/setup/js/create_agent_session.test.cjs b/actions/setup/js/create_agent_session.test.cjs index 8841a60765..15f2a2a1cc 100644 --- a/actions/setup/js/create_agent_session.test.cjs +++ b/actions/setup/js/create_agent_session.test.cjs @@ -1,21 +1,25 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; describe("create_agent_session.cjs", () => { - let mockCore, mockExec, testOutputFile; + let mockCore, mockExec, mockContext, testOutputFile; (beforeEach(() => { ((mockCore = { info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } }), - (mockExec = { exec: vi.fn().mockResolvedValue(0) }), + (mockExec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn() }), + (mockContext = { repo: { owner: "test-owner", repo: "test-repo" } }), (global.core = mockCore), (global.exec = mockExec), + (global.context = mockContext), (testOutputFile = `/tmp/test_agent_output_${Date.now()}.json`)); }), afterEach(() => { (delete global.core, delete global.exec, + delete global.context, delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED, delete process.env.GITHUB_AW_AGENT_SESSION_BASE, delete process.env.GITHUB_AW_TARGET_REPO, + delete process.env.GH_AW_AGENT_SESSION_ALLOWED_REPOS, delete process.env.GITHUB_REPOSITORY, fs.existsSync(testOutputFile) && fs.unlinkSync(testOutputFile)); })); @@ -24,7 +28,7 @@ describe("create_agent_session.cjs", () => { (fs.writeFileSync(testOutputFile, JSON.stringify(output)), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile)); }, runScript = async () => { - ((global.core = mockCore), (global.exec = mockExec)); + ((global.core = mockCore), (global.exec = mockExec), (global.context = mockContext)); const scriptPath = require("path").join(process.cwd(), "create_agent_session.cjs"); delete require.cache[require.resolve(scriptPath)]; try { From a575a5b5fb8d6d13bb249c85bfdd8102c830b5f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:32:37 +0000 Subject: [PATCH 5/7] Revert get_repository_url validation (URL helper only) get_repository_url.cjs is a URL helper that doesn't perform actual cross-repository operations. It only generates URLs for display purposes. Handlers that use it and perform actual operations are responsible for their own validation. Added documentation comment explaining this. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/create_agent_session.test.cjs | 149 +++++++++--------- actions/setup/js/get_repository_url.cjs | 29 +--- 2 files changed, 79 insertions(+), 99 deletions(-) diff --git a/actions/setup/js/create_agent_session.test.cjs b/actions/setup/js/create_agent_session.test.cjs index 15f2a2a1cc..33a2a53b57 100644 --- a/actions/setup/js/create_agent_session.test.cjs +++ b/actions/setup/js/create_agent_session.test.cjs @@ -36,7 +36,7 @@ describe("create_agent_session.cjs", () => { await main(); } catch (error) {} }; - (describe("basic functionality", () => { + ((describe("basic functionality", () => { (it("should handle missing environment variable", async () => { (delete process.env.GH_AW_AGENT_OUTPUT, await runScript(), @@ -61,85 +61,82 @@ describe("create_agent_session.cjs", () => { (createAgentOutput([{ type: "create_issue", title: "Test", body: "Content" }]), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No create-agent-session items found in agent output")); })); }), - describe("staged mode", () => { - (beforeEach(() => { - ((process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), (process.env.GITHUB_AW_AGENT_SESSION_BASE = "main"), (process.env.GITHUB_REPOSITORY = "owner/repo")); - }), - it("should preview agent sessions in staged mode", async () => { - (createAgentOutput([ - { type: "create_agent_session", body: "Implement feature X" }, - { type: "create_agent_session", body: "Fix bug Y" }, - ]), - await runScript(), - expect(mockCore.info).toHaveBeenCalled()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - (expect(summaryCall[0]).toContain("🎭 Staged Mode: Create Agent Sessions Preview"), - expect(summaryCall[0]).toContain("Implement feature X"), - expect(summaryCall[0]).toContain("Fix bug Y"), - expect(summaryCall[0]).toContain("**Base Branch:** main"), - expect(summaryCall[0]).toContain("**Target Repository:** owner/repo"), - expect(mockCore.summary.write).toHaveBeenCalled()); - }), - it("should handle task without body in staged mode", async () => { - (createAgentOutput([{ type: "create_agent_session", body: "" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("No description provided"); - }), - it("should use target repo when specified", async () => { - ((process.env.GITHUB_AW_TARGET_REPO = "org/target-repo"), createAgentOutput([{ type: "create_agent_session", body: "Test task" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Target Repository:** org/target-repo"); - })); + describe("staged mode", () => { + (beforeEach(() => { + ((process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), (process.env.GITHUB_AW_AGENT_SESSION_BASE = "main"), (process.env.GITHUB_REPOSITORY = "owner/repo")); }), - describe("agent session creation", () => { - (beforeEach(() => { - ((process.env.GITHUB_AW_AGENT_SESSION_BASE = "develop"), (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "false")); + it("should preview agent sessions in staged mode", async () => { + (createAgentOutput([ + { type: "create_agent_session", body: "Implement feature X" }, + { type: "create_agent_session", body: "Fix bug Y" }, + ]), + await runScript(), + expect(mockCore.info).toHaveBeenCalled()); + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + (expect(summaryCall[0]).toContain("🎭 Staged Mode: Create Agent Sessions Preview"), + expect(summaryCall[0]).toContain("Implement feature X"), + expect(summaryCall[0]).toContain("Fix bug Y"), + expect(summaryCall[0]).toContain("**Base Branch:** main"), + expect(summaryCall[0]).toContain("**Target Repository:** owner/repo"), + expect(mockCore.summary.write).toHaveBeenCalled()); + }), + it("should handle task without body in staged mode", async () => { + (createAgentOutput([{ type: "create_agent_session", body: "" }]), await runScript()); + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + expect(summaryCall[0]).toContain("No description provided"); }), - it("should skip tasks with empty body", async () => { - (createAgentOutput([ - { type: "create_agent_session", body: "" }, - { type: "create_agent_session", body: " \n\t " }, - ]), - await runScript(), - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent task description is empty, skipping"))); - }), - it("should log agent output content length", async () => { - (createAgentOutput([{ type: "create_agent_session", body: "Test agent session description" }]), - await runScript(), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Agent output content length:")), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found 1 create-agent-session item(s)"))); - })); + it("should use target repo when specified", async () => { + ((process.env.GITHUB_AW_TARGET_REPO = "org/target-repo"), createAgentOutput([{ type: "create_agent_session", body: "Test task" }]), await runScript()); + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + expect(summaryCall[0]).toContain("**Target Repository:** org/target-repo"); + })); + }), + describe("agent session creation", () => { + (beforeEach(() => { + ((process.env.GITHUB_AW_AGENT_SESSION_BASE = "develop"), (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "false")); }), - describe("output initialization", () => { - it("should initialize outputs to empty strings", async () => { - (delete process.env.GH_AW_AGENT_OUTPUT, await runScript(), expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", ""), expect(mockCore.setOutput).toHaveBeenCalledWith("session_url", "")); - }); + it("should skip tasks with empty body", async () => { + (createAgentOutput([ + { type: "create_agent_session", body: "" }, + { type: "create_agent_session", body: " \n\t " }, + ]), + await runScript(), + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent task description is empty, skipping"))); + }), + it("should log agent output content length", async () => { + (createAgentOutput([{ type: "create_agent_session", body: "Test agent session description" }]), + await runScript(), + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Agent output content length:")), + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found 1 create-agent-session item(s)"))); + })); + }), + describe("output initialization", () => { + it("should initialize outputs to empty strings", async () => { + (delete process.env.GH_AW_AGENT_OUTPUT, await runScript(), expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", ""), expect(mockCore.setOutput).toHaveBeenCalledWith("session_url", "")); + }); + }), + describe("edge cases", () => { + (it("should handle output with no items array", async () => { + (fs.writeFileSync(testOutputFile, JSON.stringify({})), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); }), - describe("edge cases", () => { - (it("should handle output with no items array", async () => { - (fs.writeFileSync(testOutputFile, JSON.stringify({})), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); + it("should handle output with non-array items", async () => { + (fs.writeFileSync(testOutputFile, JSON.stringify({ items: "not an array" })), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); }), - it("should handle output with non-array items", async () => { - (fs.writeFileSync(testOutputFile, JSON.stringify({ items: "not an array" })), - (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), - await runScript(), - expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); - }), - it("should use default base branch when not specified", async () => { - (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, delete process.env.GITHUB_REF_NAME, (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), createAgentOutput([{ type: "create_agent_session", body: "Test" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Base Branch:** main"); - }), - it("should use GITHUB_REF_NAME as fallback for base branch", async () => { - (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, - (process.env.GITHUB_REF_NAME = "feature-branch"), - (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), - createAgentOutput([{ type: "create_agent_session", body: "Test" }]), - await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Base Branch:** main"); - })); - })), + it("should use default base branch when not specified", async () => { + (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, delete process.env.GITHUB_REF_NAME, (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), createAgentOutput([{ type: "create_agent_session", body: "Test" }]), await runScript()); + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + expect(summaryCall[0]).toContain("**Base Branch:** main"); + }), + it("should use GITHUB_REF_NAME as fallback for base branch", async () => { + (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, + (process.env.GITHUB_REF_NAME = "feature-branch"), + (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), + createAgentOutput([{ type: "create_agent_session", body: "Test" }]), + await runScript()); + const summaryCall = mockCore.summary.addRaw.mock.calls[0]; + expect(summaryCall[0]).toContain("**Base Branch:** main"); + })); + })), describe("Cross-repository allowlist validation", () => { it("should reject target repository not in allowlist", async () => { process.env.GITHUB_AW_TARGET_REPO = "other-owner/other-repo"; @@ -191,5 +188,5 @@ describe("create_agent_session.cjs", () => { expect(mockCore.setFailed).not.toHaveBeenCalled(); expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", "123"); }); - }); + })); }); diff --git a/actions/setup/js/get_repository_url.cjs b/actions/setup/js/get_repository_url.cjs index ef8bcceda1..b31d877f06 100644 --- a/actions/setup/js/get_repository_url.cjs +++ b/actions/setup/js/get_repository_url.cjs @@ -1,11 +1,15 @@ // @ts-check /// -const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); - /** * Get the repository URL for different purposes * This helper handles trial mode where target repository URLs are different from execution context + * + * NOTE: This is a URL helper function that does not perform cross-repository operations. + * It only generates URLs for display purposes. Handlers that use this function and perform + * actual cross-repository operations (like create_issue, add_comment) are responsible for + * validating target repositories using validateTargetRepo/checkAllowedRepo from repo_helpers.cjs. + * * @param {Object} [config] - Optional config object with target-repo field * @returns {string} Repository URL */ @@ -16,30 +20,9 @@ function getRepositoryUrl(config) { // First check if there's a target-repo in config if (config && config["target-repo"]) { targetRepoSlug = config["target-repo"]; - - // Validate target repository against allowlist - const allowedRepos = parseAllowedRepos(config["allowed-repos"] || config.allowed_repos); - const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; - - const repoValidation = validateRepo(targetRepoSlug, defaultRepo, allowedRepos); - if (!repoValidation.valid) { - throw new Error(`E004: ${repoValidation.error}`); - } } else { // Fall back to env var for backward compatibility targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Validate env var target repository against allowlist - const allowedReposEnv = process.env.GH_AW_TARGET_REPO_ALLOWED_REPOS?.trim(); - const allowedRepos = parseAllowedRepos(allowedReposEnv); - const defaultRepo = `${context.repo.owner}/${context.repo.repo}`; - - const repoValidation = validateRepo(targetRepoSlug, defaultRepo, allowedRepos); - if (!repoValidation.valid) { - throw new Error(`E004: ${repoValidation.error}`); - } - } } if (targetRepoSlug) { From f9f041c0498d1b9a530c1bf049c1dcb2baa4fcac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 04:49:31 +0000 Subject: [PATCH 6/7] Add cross-repository allowlist validation tests for push_repo_memory - Add 3 test cases similar to assign_to_agent and create_agent_session tests - Test rejection of non-allowlisted repositories with E004 error - Test acceptance of allowlisted repositories - Test default repository being allowed without explicit allowlist - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_repo_memory.test.cjs | 120 +++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 5fc0f62be9..4aa583d994 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -1115,4 +1115,124 @@ describe("push_repo_memory.cjs - shell injection security tests", () => { expect(scriptContent).toContain("execGitSync(["); }); }); + + describe("Cross-repository allowlist validation", () => { + let mockCore, mockContext, mockExecGitSync, mockFs; + + beforeEach(() => { + // Reset mocks + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + }; + + mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + }; + + mockExecGitSync = vi.fn(); + mockFs = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + copyFileSync: vi.fn(), + }; + + // Set up global mocks + global.core = mockCore; + global.context = mockContext; + + // Set required env vars for main() to run + process.env.ARTIFACT_DIR = "/tmp/test-artifact"; + process.env.MEMORY_ID = "test-memory"; + process.env.BRANCH_NAME = "memory/test"; + process.env.GH_TOKEN = "test-token"; + process.env.GITHUB_RUN_ID = "123456"; + process.env.GITHUB_WORKSPACE = "/tmp/workspace"; + }); + + afterEach(() => { + delete global.core; + delete global.context; + delete process.env.TARGET_REPO; + delete process.env.REPO_MEMORY_ALLOWED_REPOS; + delete process.env.ARTIFACT_DIR; + delete process.env.MEMORY_ID; + delete process.env.BRANCH_NAME; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_RUN_ID; + delete process.env.GITHUB_WORKSPACE; + vi.resetModules(); + }); + + it("should reject target repository not in allowlist", async () => { + process.env.TARGET_REPO = "other-owner/other-repo"; + process.env.REPO_MEMORY_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + + // Mock fs to make artifact dir exist + mockFs.existsSync.mockReturnValue(true); + + // Import and run with mocked dependencies + const mockRequire = moduleName => { + if (moduleName === "fs") return mockFs; + if (moduleName === "./git_helpers.cjs") return { execGitSync: mockExecGitSync }; + return vi.requireActual(moduleName); + }; + + // Load module with mocks + vi.doMock("fs", () => mockFs); + vi.doMock("./git_helpers.cjs", () => ({ execGitSync: mockExecGitSync })); + + const { main } = await import("./push_repo_memory.cjs"); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("E004:")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("not in the allowed-repos list")); + }); + + it("should allow target repository in allowlist", async () => { + process.env.TARGET_REPO = "allowed-owner/allowed-repo"; + process.env.REPO_MEMORY_ALLOWED_REPOS = "allowed-owner/allowed-repo,other-owner/other-repo"; + + // Mock fs to make artifact dir exist with no files (to avoid git operations) + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([]); + + vi.doMock("fs", () => mockFs); + vi.doMock("./git_helpers.cjs", () => ({ execGitSync: mockExecGitSync })); + + const { main } = await import("./push_repo_memory.cjs"); + await main(); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Memory directory not found")); + }); + + it("should allow default repository without allowlist", async () => { + // Default repo is test-owner/test-repo (from mockContext) + process.env.TARGET_REPO = "test-owner/test-repo"; + // No REPO_MEMORY_ALLOWED_REPOS set + + // Mock fs to make artifact dir not exist (quick exit without error) + mockFs.existsSync.mockReturnValue(false); + + vi.doMock("fs", () => mockFs); + vi.doMock("./git_helpers.cjs", () => ({ execGitSync: mockExecGitSync })); + + const { main } = await import("./push_repo_memory.cjs"); + await main(); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Memory directory not found")); + }); + }); }); From 519ac35ecf6d8f0c9dea60c44b5551d96e91eec5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Feb 2026 05:00:01 +0000 Subject: [PATCH 7/7] Add changeset [skip-ci] --- .changeset/patch-cross-repo-allowlist-validation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-cross-repo-allowlist-validation.md diff --git a/.changeset/patch-cross-repo-allowlist-validation.md b/.changeset/patch-cross-repo-allowlist-validation.md new file mode 100644 index 0000000000..6fa492b108 --- /dev/null +++ b/.changeset/patch-cross-repo-allowlist-validation.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Enforce cross-repository allowlists in assign, session, and memory handlers so unsafe target repositories now trigger the standardized E004 error.