From 6c61f80637b5e14c2ca9e5f83fc6e03d5f2e851d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:24:16 +0000 Subject: [PATCH 1/3] Initial plan From 3194e91654250e0f217f4e7ab4c0072118c349e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:37:37 +0000 Subject: [PATCH 2/3] safe-outputs: validate extracted base branch with git check-ref-format Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../extract_base_branch_from_agent_output.cjs | 20 ++++++++++++++++--- ...act_base_branch_from_agent_output.test.cjs | 14 ++++++++++++- ...-outputs-base-branch-from-event-context.md | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/extract_base_branch_from_agent_output.cjs b/actions/setup/js/extract_base_branch_from_agent_output.cjs index c7165de0c31..98e78be4db8 100644 --- a/actions/setup/js/extract_base_branch_from_agent_output.cjs +++ b/actions/setup/js/extract_base_branch_from_agent_output.cjs @@ -2,9 +2,10 @@ /// const fs = require("fs"); +const { spawnSync } = require("child_process"); const AGENT_OUTPUT_PATH = "/tmp/gh-aw/agent_output.json"; -const SAFE_BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/; +const MAX_BRANCH_NAME_LENGTH = 255; /** * @param {string} itemRepo @@ -49,9 +50,22 @@ function extractBaseBranchFromAgentOutput(opts = {}) { async function main() { const baseBranch = extractBaseBranchFromAgentOutput(); if (!baseBranch) return; - if (!SAFE_BRANCH_NAME_REGEX.test(baseBranch) || baseBranch.length > 255) return; + if (!isValidBaseBranchName(baseBranch)) return; core.setOutput("base-branch", baseBranch); core.info(`Extracted base branch from safe output: ${baseBranch}`); } -module.exports = { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, main }; +/** + * @param {string} branchName + * @returns {boolean} + */ +function isValidBaseBranchName(branchName) { + if (!branchName || branchName.length > MAX_BRANCH_NAME_LENGTH) { + return false; + } + + const result = spawnSync("git", ["check-ref-format", "--branch", branchName], { stdio: "ignore" }); + return !result.error && result.status === 0; +} + +module.exports = { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, isValidBaseBranchName, main }; diff --git a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs index 91e33791053..4e4c9b97462 100644 --- a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs +++ b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import fs from "fs"; import path from "path"; -import { extractBaseBranchFromAgentOutput, isSameWorkflowRepo } from "./extract_base_branch_from_agent_output.cjs"; +import { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, isValidBaseBranchName } from "./extract_base_branch_from_agent_output.cjs"; describe("extract_base_branch_from_agent_output", () => { it("matches fully-qualified repos", () => { @@ -70,4 +70,16 @@ describe("extract_base_branch_from_agent_output", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("accepts valid git branch names used in safe outputs", () => { + expect(isValidBaseBranchName("feature/x")).toBe(true); + expect(isValidBaseBranchName("release/v1.2+hotfix")).toBe(true); + }); + + it("rejects invalid git branch names even if they look regex-safe", () => { + expect(isValidBaseBranchName("foo..bar")).toBe(false); + expect(isValidBaseBranchName("main.lock")).toBe(false); + expect(isValidBaseBranchName(".foo")).toBe(false); + expect(isValidBaseBranchName("foo/.bar")).toBe(false); + }); }); diff --git a/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md b/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md index b108aba02bd..0551c458011 100644 --- a/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md +++ b/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md @@ -56,7 +56,7 @@ Insert a `gh api` call into the workflow itself (before checkout) to fetch the P ### Workflow Extraction Step 1. Every compiled workflow job that performs a safe-outputs checkout **MUST** include an `extract-base-branch` step that runs after the agent artifact download and before the checkout step. -2. The extraction step **MUST** validate the extracted branch name against the pattern `^[a-zA-Z0-9/_.-]+$` and enforce a maximum length of 255 characters before writing to `GITHUB_OUTPUT`. +2. The extraction step **MUST** validate the extracted branch name using `git check-ref-format --branch` semantics and enforce a maximum length of 255 characters before writing to `GITHUB_OUTPUT`. 3. The extraction step **MUST NOT** fail the workflow if `agent_output.json` is absent or if no matching safe-output entry contains `base_branch`; it **MUST** exit successfully (silently) in those cases. 4. Checkout steps **MUST** lead the `ref` expression with `steps.extract-base-branch.outputs.base-branch` and **SHOULD** retain event-context fallbacks (`github.base_ref`, `github.event.pull_request.base.ref`, etc.) for backward compatibility. From 2a60231f48586d97c0e740550eb4bf74fc7a6bd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:50:19 +0000 Subject: [PATCH 3/3] safe-outputs: fix @{-N} bypass, add timeout, clarify fail-closed comment, add boundary tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup-cli/install.sh | 2 +- .../js/extract_base_branch_from_agent_output.cjs | 6 +++++- .../extract_base_branch_from_agent_output.test.cjs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh index c7a5ed2ffed..1c565c9e893 100755 --- a/actions/setup-cli/install.sh +++ b/actions/setup-cli/install.sh @@ -1,7 +1,7 @@ #!/bin/bash set +o histexpand -# Kept in sync with actions/setup-cli/install.sh — edit this file, then copy to that path. +# Script sync note: install-gh-aw.sh is canonical. actions/setup-cli/install.sh is copied from install-gh-aw.sh. # Script to download and install gh-aw binary for the current OS and architecture # Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin) diff --git a/actions/setup/js/extract_base_branch_from_agent_output.cjs b/actions/setup/js/extract_base_branch_from_agent_output.cjs index 98e78be4db8..84c737b2618 100644 --- a/actions/setup/js/extract_base_branch_from_agent_output.cjs +++ b/actions/setup/js/extract_base_branch_from_agent_output.cjs @@ -64,7 +64,11 @@ function isValidBaseBranchName(branchName) { return false; } - const result = spawnSync("git", ["check-ref-format", "--branch", branchName], { stdio: "ignore" }); + // Use refs/heads/ to validate as a literal ref, not a branch expression. + // --branch also accepts @{-N} git expressions; refs/heads/ form correctly rejects them. + // Fail-closed: if git is unavailable (ENOENT) or times out (ETIMEDOUT), result.error is set + // and we return false, safely dropping the base branch rather than passing an invalid value. + const result = spawnSync("git", ["check-ref-format", `refs/heads/${branchName}`], { stdio: "ignore", timeout: 5000 }); return !result.error && result.status === 0; } diff --git a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs index 4e4c9b97462..e4523bafa01 100644 --- a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs +++ b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs @@ -82,4 +82,16 @@ describe("extract_base_branch_from_agent_output", () => { expect(isValidBaseBranchName(".foo")).toBe(false); expect(isValidBaseBranchName("foo/.bar")).toBe(false); }); + + it("rejects git branch expressions (@{-N} notation)", () => { + expect(isValidBaseBranchName("@{-1}")).toBe(false); + expect(isValidBaseBranchName("@{-2}")).toBe(false); + }); + + it("enforces the 255-character length limit", () => { + const atLimit = "a".repeat(255); + const overLimit = "a".repeat(256); + expect(isValidBaseBranchName(atLimit)).toBe(true); + expect(isValidBaseBranchName(overLimit)).toBe(false); + }); });