Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions actions/setup/sh/restore_base_github_folders.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ set +o histexpand
# (e.g. ".agents .claude .codex .gemini .github")
# GH_AW_AGENT_FILES - space-separated list of root files to restore
# (e.g. "AGENTS.md CLAUDE.md GEMINI.md")
# DEFAULT_BRANCH - trusted repository default branch supplied by the
# compiler for workflows with safe outputs
#
# Exit codes:
# 0 - Success
Expand All @@ -38,6 +40,19 @@ SRC="/tmp/gh-aw/base"
IFS=' ' read -ra FOLDERS <<< "${GH_AW_AGENT_FOLDERS:-}"
IFS=' ' read -ra ROOT_FILES <<< "${GH_AW_AGENT_FILES:-}"

# A trusted base checkout may have advanced since activation. Replacing its config
# with the old snapshot would turn concurrent base changes into agent-authored
# reverts. Only an exact commit match is sufficient; a PR head can also descend
# from the base. Missing refs (including checkout-disabled runs) retain restoration.
if [ -n "${DEFAULT_BRANCH:-}" ] &&
HEAD_SHA=$(git -C "${WORKSPACE}" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) &&
BASE_SHA=$(git -C "${WORKSPACE}" rev-parse --verify "refs/remotes/origin/${DEFAULT_BRANCH}^{commit}" 2>/dev/null) &&
[ "${HEAD_SHA}" = "${BASE_SHA}" ]; then
echo "Keeping agent config folders from the checked-out trusted base commit ${HEAD_SHA}"
FOLDERS=()
ROOT_FILES=()
fi
Comment on lines +47 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] 🔴 Critical: git rev-parse --verify "refs/remotes/origin/${DEFAULT_BRANCH}^{commit}" does not do an exact-path ref lookup — it falls back through git's standard ref search order (including refs/heads/<name>). This reopens the exact attack this PR is meant to close.

💡 Exploit and fix

A fork PR can name its branch refs/remotes/origin/main. git checkout -B <headRef> origin/pr-head then creates a local branch literally at refs/heads/refs/remotes/origin/main. When the workspace has no real refs/remotes/origin/<default> (e.g. pull_request runs where actions/checkout only fetched refs/pull/N/merge, or the PR base isn't the default branch), rev-parse resolves BASE_SHA to the attacker's commit, which equals HEAD_SHA — so FOLDERS/ROOT_FILES are emptied and restoration is skipped, leaving attacker-controlled .github/, .claude/, skills, and AGENTS.md in place.

I reproduced this locally: creating a branch named refs/remotes/origin/main and checking it out causes the current script to treat the attacker commit as the trusted base and skip restoration.

Fix: replace the rev-parse call with an exact-path lookup that can't fall back to other namespaces:

BASE_SHA=$(git -C "${WORKSPACE}" show-ref --verify --hash "refs/remotes/origin/${DEFAULT_BRANCH}" 2>/dev/null)

I verified this rejects the attacker-branch scenario (exits non-zero, no match) while still resolving a genuine remote-tracking ref. Please add a regression test for this exact scenario (PR head named refs/remotes/origin/<default-branch>) alongside the existing table-driven cases in pr_test.go.

@copilot please address this.


for FOLDER in "${FOLDERS[@]+"${FOLDERS[@]}"}"; do
SNAPSHOT="${SRC}/${FOLDER}"
DEST="${WORKSPACE}/${FOLDER}"
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/engines/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ engine:

gh-aw enables the CLI's `EXTENSIONS` feature flag, loads project extensions in prompt mode, and pre-approves workflow runs. SDK sessions honor the same switch through their tool catalog and permission handler, without granting additional shell or MCP access. Save reusable definitions and supporting files under `.github/extensions/<name>/extension.mjs`, and explicitly request the registered workflow by name in the prompt. Their agents remain subject to the configured tool permissions and sandbox policies.

gh-aw's existing engine-config restoration snapshots the engine-declared folders, including Copilot's `.github/`, recursively from the activation checkout and restores them after agent checkouts, including when `checkout.pull-request: false` is set. Restoring the entire configuration directory prevents PR-head extension code, settings, and hooks from replacing trusted definitions. Use `ambient-folders` for supporting files elsewhere in the repository. Dynamic workflow availability also depends on the Copilot account; enabling extensions does not make workflows available with a BYOK provider.
gh-aw's existing engine-config restoration snapshots the engine-declared folders, including Copilot's `.github/`, recursively from the activation checkout and restores them after agent checkouts, including when `checkout.pull-request: false` is set. For workflows with safe outputs, a checkout whose HEAD exactly matches the local default-branch remote-tracking ref keeps its current configuration instead, preserving base-branch updates that landed after activation. PR heads that merely descend from the base still receive snapshot restoration. Restoring the entire configuration directory prevents PR-head extension code, settings, and hooks from replacing trusted definitions. Use `ambient-folders` for supporting files elsewhere in the repository. Dynamic workflow availability also depends on the Copilot account; enabling extensions does not make workflows available with a BYOK provider.

The repository's [Copilot dynamic workflow smoke test](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-copilot-dynamic-workflow.md) invokes the packaged `smoke-copilot-dynamic-workflow` extension by name. It verifies a relative module import, a nested hidden support file, durable steps, and one structured subagent result. Run it with `gh aw run smoke-copilot-dynamic-workflow`, or dispatch **Smoke Trigger** with `dynamic-workflow: true` to exercise a feature branch before the new workflow is registered on the default branch.

Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ The agent's commits are packaged as a **git bundle** and uploaded as an Actions

If the base branch advances between agent start and `safe_outputs` apply, the PR is created slightly behind the current base — normal behavior the author can address with a rebase. If a non-fast-forward race occurs during the push itself, the job creates a fallback PR from a temporary branch so no changes are lost.

If the default branch advances between activation and the agent checkout, configuration restoration keeps the newer checkout's config folders and root instruction files when HEAD exactly matches its local default-branch remote-tracking ref. It does not overwrite them with the older activation snapshot. PR or feature-branch checkouts and checkouts without that local ref retain snapshot restoration; no additional fetch is required.

An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution.

:::caution[Shallow checkout and large monorepos]
Expand Down
98 changes: 98 additions & 0 deletions pkg/workflow/pr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,104 @@ func TestCanRestoreAgentConfigFolders(t *testing.T) {
}
}

func TestRestoreConfigWhenBaseAdvances(t *testing.T) {
restoreScript, err := os.ReadFile(filepath.Join("..", "..", "actions", "setup", "sh", "restore_base_github_folders.sh"))
require.NoError(t, err)
for _, mode := range []string{"trusted base", "PR head", "missing base ref", "missing default branch input"} {
t.Run(mode, func(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
base := filepath.Join(root, "base")
require.NoError(t, os.MkdirAll(workspace, 0755))
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = workspace
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
return strings.TrimSpace(string(output))
}
write := func(name, content string) {
t.Helper()
dest := filepath.Join(workspace, name)
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0755))
require.NoError(t, os.WriteFile(dest, []byte(content), 0644))
}
git("init", "-q")
git("config", "user.name", "Test")
git("config", "user.email", "test@example.com")
git("config", "commit.gpgsign", "false")
write(".github/workflows/publish.yml", "original workflow\n")
write(".claude/removed.md", "original config\n")
write("AGENTS.md", "original instructions\n")
write("result.txt", "before\n")
git("add", ".")
git("commit", "-qm", "Activation base A")
require.NoError(t, os.MkdirAll(filepath.Join(base, ".github", "workflows"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(base, ".claude"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(base, ".github", "workflows", "publish.yml"), []byte("original workflow\n"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(base, ".claude", "removed.md"), []byte("original config\n"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(base, "AGENTS.md"), []byte("original instructions\n"), 0644))

write(".github/workflows/publish.yml", "original workflow\nconcurrent base step\n")
write(".claude/new.md", "concurrent config\n")
require.NoError(t, os.Remove(filepath.Join(workspace, ".claude", "removed.md")))
require.NoError(t, os.Remove(filepath.Join(workspace, "AGENTS.md")))
git("add", "-A")
git("commit", "-qm", "Concurrent base B")
baseSHA := git("rev-parse", "HEAD")
defaultBranch := "main"
if mode != "missing base ref" {
git("update-ref", "refs/remotes/origin/"+defaultBranch, baseSHA)
}
if mode == "PR head" {
// An ancestor match would incorrectly trust this PR's config.
git("checkout", "-qb", "untrusted-pr")
write(".github/workflows/publish.yml", "untrusted workflow\n")
write(".claude/injected.md", "untrusted config\n")
write("AGENTS.md", "untrusted instructions\n")
git("add", "-A")
git("commit", "-qm", "Untrusted PR")
}
write(".mcp.json", `{"mcpServers":{"untrusted":{}}}`)
scriptPath := filepath.Join(root, "restore.sh")
isolatedScript := strings.ReplaceAll(string(restoreScript), `SRC="/tmp/gh-aw/base"`, "SRC="+shellEscapeArg(base))
require.NoError(t, os.WriteFile(scriptPath, []byte(isolatedScript), 0644))
if mode == "missing default branch input" {
defaultBranch = ""
}
cmd := exec.Command("bash", scriptPath)
cmd.Env = append(os.Environ(),
"GITHUB_WORKSPACE="+workspace,
"GH_AW_AGENT_FOLDERS=.github .claude",
"GH_AW_AGENT_FILES=AGENTS.md",
"DEFAULT_BRANCH="+defaultBranch,
)
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
assert.NoFileExists(t, filepath.Join(workspace, ".mcp.json"))
if mode == "trusted base" {
assert.Contains(t, string(output), "checked-out trusted base commit")
assert.Empty(t, git("status", "--porcelain"))
write("result.txt", "agent result\n")
git("add", "-A")
git("commit", "-qm", "Agent change")
assert.Equal(t, baseSHA, git("rev-parse", "HEAD^"))
assert.Equal(t, "result.txt", git("diff", "--name-only", baseSHA, "HEAD"))
} else {
content, err := os.ReadFile(filepath.Join(workspace, ".github", "workflows", "publish.yml"))
require.NoError(t, err)
assert.Equal(t, "original workflow\n", string(content))
assert.FileExists(t, filepath.Join(workspace, ".claude", "removed.md"))
assert.NoFileExists(t, filepath.Join(workspace, ".claude", "new.md"))
assert.NoFileExists(t, filepath.Join(workspace, ".claude", "injected.md"))
assert.FileExists(t, filepath.Join(workspace, "AGENTS.md"))
}
})
}
}

func TestEngineConfigRestorePrecedesAgentSteps(t *testing.T) {
enabled, disabled := true, false
for _, engineID := range []string{"claude", "copilot"} {
Expand Down
Loading