From 5553049b46b752c7bfd419dc41baaf8d15598483 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:30:56 +0000 Subject: [PATCH 1/5] Initial plan From 687c879a9629285c2b47487e45e21830645b9974 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:43:42 +0000 Subject: [PATCH 2/5] fix(SEC-005): add validateTargetRepo allowlist checks to cross-repo handlers Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dynamic_checkout.cjs | 15 ++++++++++++++- actions/setup/js/extra_empty_commit.cjs | 16 +++++++++++++++- actions/setup/js/find_repo_checkout.cjs | 15 ++++++++++++++- actions/setup/js/get_base_branch.cjs | 17 +++++++++++++++++ actions/setup/js/safe_outputs_tools_loader.cjs | 10 ++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/dynamic_checkout.cjs b/actions/setup/js/dynamic_checkout.cjs index 8f420f2aaa6..12d1dc57b5c 100644 --- a/actions/setup/js/dynamic_checkout.cjs +++ b/actions/setup/js/dynamic_checkout.cjs @@ -1,6 +1,9 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); +const { ERR_VALIDATION } = require("./error_codes.cjs"); + /** * Dynamic repository checkout utilities for multi-repo scenarios * Enables switching between different repositories during handler execution @@ -60,10 +63,20 @@ async function checkoutRepo(repoSlug, token, options = {}) { if (parts.length !== 2 || !parts[0] || !parts[1]) { return { success: false, - error: `Invalid repository slug: ${repoSlug}. Expected format: owner/repo`, + error: `${ERR_VALIDATION}: Invalid repository slug: ${repoSlug}. Expected format: owner/repo`, }; } + // Validate target repo against configured allowlist before any git operations + const allowedRepos = parseAllowedRepos(options.allowedRepos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(repoSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + return { success: false, error: `${ERR_VALIDATION}: ${validation.error}` }; + } + } + const [owner, repo] = parts; core.info(`Switching checkout to repository: ${repoSlug}`); diff --git a/actions/setup/js/extra_empty_commit.cjs b/actions/setup/js/extra_empty_commit.cjs index b58b0a3ac91..4a7a2d64027 100644 --- a/actions/setup/js/extra_empty_commit.cjs +++ b/actions/setup/js/extra_empty_commit.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); + /** * @fileoverview Extra Empty Commit Helper * @@ -49,7 +51,7 @@ function isCrossRepoTarget(repoOwner, repoName) { * modifications on multi-commit branches and reducing loop risk. * @returns {Promise<{success: boolean, skipped?: boolean, error?: string}>} */ -async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount }) { +async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount, allowedRepos: allowedReposInput }) { const token = process.env.GH_AW_CI_TRIGGER_TOKEN; if (!token || !token.trim()) { @@ -57,6 +59,18 @@ async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMes return { success: true, skipped: true }; } + // Validate target repository against allowlist before any git operations + const allowedRepos = parseAllowedRepos(allowedReposInput); + if (allowedRepos.size > 0) { + const targetRepo = `${repoOwner}/${repoName}`; + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetRepo, defaultRepo, allowedRepos); + if (!validation.valid) { + core.warning(`ERR_VALIDATION: ${validation.error}`); + return { success: false, error: validation.error }; + } + } + // Cross-repo guard: never push an extra empty commit to a different repository. // A token is needed to create the PR and that will trigger events anyway. if (isCrossRepoTarget(repoOwner, repoName)) { diff --git a/actions/setup/js/find_repo_checkout.cjs b/actions/setup/js/find_repo_checkout.cjs index 38a556b4fdc..2421b303d5c 100644 --- a/actions/setup/js/find_repo_checkout.cjs +++ b/actions/setup/js/find_repo_checkout.cjs @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const { execGitSync } = require("./git_helpers.cjs"); +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); /** * Debug logging helper - logs to stderr when DEBUG env var matches @@ -129,9 +130,11 @@ function getRemoteOriginUrl(repoPath) { * * @param {string} repoSlug - The repository slug to find (owner/repo format) * @param {string} [workspaceRoot] - The workspace root to search from + * @param {Object} [options] - Additional options + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for validation * @returns {Object} Result with success status and path or error */ -function findRepoCheckout(repoSlug, workspaceRoot) { +function findRepoCheckout(repoSlug, workspaceRoot, options = {}) { const ws = workspaceRoot || process.env.GITHUB_WORKSPACE || process.cwd(); const targetSlug = normalizeRepoSlug(repoSlug); @@ -144,6 +147,16 @@ function findRepoCheckout(repoSlug, workspaceRoot) { }; } + // Validate target repo against configured allowlist before searching + const allowedRepos = parseAllowedRepos(options.allowedRepos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + } + // Find all git directories in the workspace const gitDirs = findGitDirectories(ws); debugLog(`Found ${gitDirs.length} git directories: ${gitDirs.join(", ")}`); diff --git a/actions/setup/js/get_base_branch.cjs b/actions/setup/js/get_base_branch.cjs index 1c5e220d0d2..ac2a692c2fd 100644 --- a/actions/setup/js/get_base_branch.cjs +++ b/actions/setup/js/get_base_branch.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); + /** * Get the base branch name, resolving dynamically based on event context. * @@ -40,6 +42,21 @@ async function getBaseBranch(targetRepo = null) { if (typeof github !== "undefined") { const repoOwner = targetRepo?.owner ?? context.repo.owner; const repoName = targetRepo?.repo ?? context.repo.repo; + + // Validate target repo against allowlist before any API calls + const targetRepoSlug = `${repoOwner}/${repoName}`; + const allowedRepos = parseAllowedRepos(process.env.GH_AW_ALLOWED_REPOS); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetRepoSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + if (typeof core !== "undefined") { + core.warning(`ERR_VALIDATION: ${validation.error}`); + } + return process.env.DEFAULT_BRANCH || "main"; + } + } + const { data: pr } = await github.rest.pulls.get({ owner: repoOwner, repo: repoName, diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index b800d459519..c1b9d3d313a 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -1,6 +1,7 @@ // @ts-check const { getErrorMessage } = require("./error_helpers.cjs"); +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); const fs = require("fs"); @@ -100,6 +101,15 @@ function registerPredefinedTools(server, tools, config, registerTool, normalizeT if (tool.name === "create_pull_request" && config.create_pull_request) { const targetRepo = config.create_pull_request["target-repo"]; if (targetRepo) { + // Validate the configured target-repo against the allowed-repos list + const allowedRepos = parseAllowedRepos(config.create_pull_request.allowed_repos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(config.create_pull_request); + const validation = validateTargetRepo(targetRepo, defaultRepo, allowedRepos); + if (!validation.valid) { + server.debug(`WARNING: SEC-005: ${validation.error}`); + } + } toolToRegister = JSON.parse(JSON.stringify(tool)); toolToRegister.description += ` Note: This workflow is configured to create pull requests in '${targetRepo}'. You do not need to specify the repo parameter.`; if (toolToRegister.inputSchema && toolToRegister.inputSchema.properties && toolToRegister.inputSchema.properties.repo) { From c678bb529988bf152d96f89f4d7e23a0e21d5220 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 04:49:00 +0000 Subject: [PATCH 3/5] Add changeset [skip-ci] --- .changeset/patch-validate-cross-repo-allowlist.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-validate-cross-repo-allowlist.md diff --git a/.changeset/patch-validate-cross-repo-allowlist.md b/.changeset/patch-validate-cross-repo-allowlist.md new file mode 100644 index 00000000000..656f06d4b66 --- /dev/null +++ b/.changeset/patch-validate-cross-repo-allowlist.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add allowlist validation for the cross-repo helpers so `targetRepo` parameters and GH_AW_ALLOWED_REPOS checks now guard git and API work before any operations. From d628e24222ff944b99bf0d4c28aad3d421870eac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 04:49:58 +0000 Subject: [PATCH 4/5] ci: trigger checks From 8d0af6d607bb1a3c890cddf1e27b998cc8529fa0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:03:07 +0000 Subject: [PATCH 5/5] fix(SEC-005): fix TypeScript errors in JSDoc annotations for allowedRepos params Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dynamic_checkout.cjs | 1 + actions/setup/js/extra_empty_commit.cjs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/dynamic_checkout.cjs b/actions/setup/js/dynamic_checkout.cjs index 12d1dc57b5c..f565bee902b 100644 --- a/actions/setup/js/dynamic_checkout.cjs +++ b/actions/setup/js/dynamic_checkout.cjs @@ -54,6 +54,7 @@ async function getCurrentCheckoutRepo() { * @param {string} token - GitHub token for authentication * @param {Object} options - Additional options * @param {string} [options.baseBranch] - Base branch to checkout (defaults to 'main') + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for allowlist validation * @returns {Promise} Result with success status */ async function checkoutRepo(repoSlug, token, options = {}) { diff --git a/actions/setup/js/extra_empty_commit.cjs b/actions/setup/js/extra_empty_commit.cjs index 4a7a2d64027..de58a9cb4a1 100644 --- a/actions/setup/js/extra_empty_commit.cjs +++ b/actions/setup/js/extra_empty_commit.cjs @@ -49,6 +49,7 @@ function isCrossRepoTarget(repoOwner, repoName) { * @param {number} [options.newCommitCount] - Number of new commits being pushed. Only pushes the * empty commit when exactly 1 new commit was pushed, preventing accidental workflow-file * modifications on multi-commit branches and reducing loop risk. + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for allowlist validation * @returns {Promise<{success: boolean, skipped?: boolean, error?: string}>} */ async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount, allowedRepos: allowedReposInput }) { @@ -67,7 +68,7 @@ async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMes const validation = validateTargetRepo(targetRepo, defaultRepo, allowedRepos); if (!validation.valid) { core.warning(`ERR_VALIDATION: ${validation.error}`); - return { success: false, error: validation.error }; + return { success: false, error: validation.error ?? "" }; } }