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);
+ });
});