Skip to content
Open
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
48 changes: 47 additions & 1 deletion actions/setup/sh/commit_cache_memory_git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,54 @@ fi

cd "$CACHE_DIR"

scrub_git_config_entries() {

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] scrub_git_config_entries calls git config --local, but if .git was just reinitialized (empty config), there is nothing to unset — that is fine. However if the grep -E -i match produces zero lines the while loop body never runs and exits 0. The subtle risk: git config --name-only --list may emit keys with uppercase prefixes that the ^${key_prefix}\. pattern misses on case-sensitive filesystems if key_prefix is passed in mixed case. The -i flag on grep mitigates this, but key_prefix is always lower-case at the call sites, so the -i is redundant and its presence may give false confidence.

💡 Suggestion

Document the lower-case contract in the function comment, or normalise with tr '[:upper:]' '[:lower:]' before the grep, so callers never need to think about case:

| tr '[:upper:]' '[:lower:]' | grep -E "^${key_prefix}\."

@copilot please address this.

local key_prefix="$1"
while IFS= read -r key_name; do
[ -n "$key_name" ] || continue
git config --unset-all "$key_name" >/dev/null 2>&1 || true
done < <(
git config --local --name-only --list 2>/dev/null \
| grep -E -i "^${key_prefix}\\." \
| sort -u
)
}

has_symlinked_git_metadata() {
[ -L .git ] || [ -n "$(find .git -type l -print -quit 2>/dev/null)" ]
}

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] After rm -rf .git && git init -q, the new repo has no user.email/user.name yet, so if the script exits early between git init and the git config user.* lines below, the subsequent commit would use whatever global/system identity is configured — potentially none, causing an error with unhelpful output.

💡 Suggestion

Move the git config user.* lines immediately after git init (inside the if block), so the identity is always set before any other git operation:

if has_symlinked_git_metadata; then
  rm -rf .git
  git init -q
  git config user.email "gh-aw@github.com"
  git config user.name "gh-aw"
fi

@copilot please address this.


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.

shrink: four separate --unset-all calls for single core.* keys, each with its own >/dev/null 2>&1 || true. Loop: for k in attributesFile fsmonitor sshCommand hooksPath; do git config --unset-all "core.$k" >/dev/null 2>&1 || true; done, saves 3 lines.

if has_symlinked_git_metadata; then
echo "Refusing to mutate symlinked cache-memory git metadata" >&2
exit 1
fi

# Agent-written cache state may contain hooks or configuration that executes

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] core.worktree is not scrubbed — an agent-written value would cause git add -A to stage files from an agent-controlled directory instead of the cache dir.

💡 Suggested fix

Add this alongside the other core.* unsets:

git config --unset-all core.worktree >/dev/null 2>&1 || true

core.worktree changes what directory git treats as the working tree, so it's a high-severity execution-redirection surface.

@copilot please address this.

# during staging or committing. Clear those command surfaces before either step.
if [ -d .git/hooks ]; then
find .git/hooks -mindepth 1 -maxdepth 1 \( -type f -o -type l \) ! -name '*.sample' -delete

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] core.gitProxy is not scrubbed — it accepts a shell command that git executes for every remote URL match, making it a persistent command-injection surface even if no push/fetch occurs in this script.

💡 Suggested fix
git config --unset-all core.gitProxy >/dev/null 2>&1 || true

Also consider uploadpack.packObjectsHook and uploadpack.packObjectsHook for completeness, which likewise execute arbitrary commands.

@copilot please address this.

fi
mkdir -p .git/info
rm -f .git/info/exclude .git/info/attributes .git/info/grafts .git/info/sparse-checkout
rm -f .git/config.worktree

git config --unset-all extensions.worktreeConfig >/dev/null 2>&1 || true
git config --unset-all core.attributesFile >/dev/null 2>&1 || true
git config --unset-all core.fsmonitor >/dev/null 2>&1 || true
git config --unset-all core.sshCommand >/dev/null 2>&1 || true
git config --unset-all core.hooksPath >/dev/null 2>&1 || true

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.

core.worktree not scrubbed — an agent could write core.worktree = /sensitive/path to the local git config, causing git add -A to stage files from an arbitrary path instead of the cache directory. Add:

git config --unset-all core.worktree >/dev/null 2>&1 || true

after the existing core.hooksPath --unset-all line.

@copilot please address this.

git config --unset-all core.worktree >/dev/null 2>&1 || true
git config --unset-all core.gitProxy >/dev/null 2>&1 || true
scrub_git_config_entries include
scrub_git_config_entries includeif
scrub_git_config_entries credential
scrub_git_config_entries alias
scrub_git_config_entries filter
scrub_git_config_entries merge

git config user.email "gh-aw@github.com"
git config user.name "gh-aw"
git config core.hooksPath /dev/null
git config core.fsmonitor false

# --- Log cache directory contents before commit ---
echo "=== Cache directory: non-git files being committed ==="
Expand All @@ -41,7 +87,7 @@ git add -A

# Commit on the current integrity branch; allow empty commits in case
# the agent made no changes (idempotent).
if git commit --allow-empty -m "run-${RUN_ID}" -q 2>/tmp/gh-aw-commit-err; then
if git -c commit.gpgSign=false commit --no-verify --allow-empty -m "run-${RUN_ID}" -q 2>/tmp/gh-aw-commit-err; then
echo "Cache memory git commit complete (run: $RUN_ID)"
else
# Distinguish "nothing to commit" (benign) from real errors
Expand Down
112 changes: 112 additions & 0 deletions actions/setup/sh/commit_cache_memory_git_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="${SCRIPT_DIR}/commit_cache_memory_git.sh"

TESTS_PASSED=0
TESTS_FAILED=0
WORKSPACE="$(mktemp -d)"

cleanup() {
rm -rf "${WORKSPACE}"
}
trap cleanup EXIT

assert() {
local name="$1"
shift
if "$@" 2>/dev/null; then
echo " ✓ ${name}"
TESTS_PASSED=$((TESTS_PASSED + 1))

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.

assert helper uses eval — the test harness evaluates condition strings with eval "${condition}". If a test condition accidentally contains shell-special characters (e.g., from a path with spaces or glob chars), it may silently misfire. Consider replacing eval with bash -c "${condition}" or restructuring assertions to pass the condition as a proper subshell.

@copilot please address this.

else
echo " ✗ ${name}"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}

run_script() {
GH_AW_CACHE_DIR="$1" GITHUB_RUN_ID="test-run" bash "${SCRIPT}" 2>&1
}

echo "Testing commit_cache_memory_git.sh"
echo ""

echo "Test 1: Script syntax is valid"
assert "script passes bash -n" bash -n "${SCRIPT}"
echo ""

echo "Test 2: Agent-controlled hooks and filters cannot execute"
D="${WORKSPACE}/test2"
SENTINEL_HOOK="${WORKSPACE}/hook-executed"
SENTINEL_FILTER="${WORKSPACE}/filter-executed"
SENTINEL_SIGNING="${WORKSPACE}/signing-executed"
mkdir -p "${D}/evil-hooks"
git -C "${D}" init -q
git -C "${D}" config user.email "test@example.com"
git -C "${D}" config user.name "Test"
touch "${D}/initial"
git -C "${D}" add initial
git -C "${D}" commit -qm initial
git -C "${D}" config extensions.worktreeConfig true
git -C "${D}" config --worktree core.hooksPath "${D}/evil-hooks"
git -C "${D}" config --worktree filter.evil.clean "touch ${SENTINEL_FILTER}"
git -C "${D}" config core.worktree "${WORKSPACE}"
git -C "${D}" config core.gitProxy "touch ${WORKSPACE}/proxy-executed"
git -C "${D}" config commit.gpgSign true
git -C "${D}" config gpg.program "${D}/evil-gpg"
cat > "${D}/evil-hooks/post-commit" <<EOF
#!/usr/bin/env bash
touch "${SENTINEL_HOOK}"
EOF

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.

[/tdd] The test only covers core.hooksPath (external hooks dir) and filter.*. It does not exercise the symlinked-.git reinitialisation path (has_symlinked_git_metadata), which is one of the two major new code paths added. A bug there would go undetected.

💡 Suggested additional test
echo "Test 3: Symlinked .git metadata is reinitialized"
D3="${WORKSPACE}/test3"
REAL_GIT="${WORKSPACE}/real-git"
git -C "$(mkdir -p "${D3}" && echo ${D3})" init -q
mv "${D3}/.git" "${REAL_GIT}"
ln -s "${REAL_GIT}" "${D3}/.git"   # simulate symlinked metadata
run_script "${D3}" >/dev/null
assert "symlinked .git was replaced with real dir" "[ ! -L '${D3}/.git' ]"
assert "repo still functional after reinit" "git -C '${D3}' status"

@copilot please address this.

cat > "${D}/evil-gpg" <<EOF
#!/usr/bin/env bash
touch "${SENTINEL_SIGNING}"
exit 1
EOF
chmod +x "${D}/evil-hooks/post-commit" "${D}/evil-gpg"
printf 'content\n' > "${D}/agent-data"
printf 'agent-data filter=evil\n' > "${D}/.gitattributes"
run_script "${D}" >/dev/null
assert "post-commit hook was not executed" test ! -e "${SENTINEL_HOOK}"
assert "clean filter was not executed" test ! -e "${SENTINEL_FILTER}"
assert "signing program was not executed" test ! -e "${SENTINEL_SIGNING}"
assert "hooks path hardened" test "$(git -C "${D}" config --default '' core.hooksPath)" = "/dev/null"
assert "worktree override removed" test -z "$(git -C "${D}" config --local --get core.worktree || true)"
assert "git proxy removed" test -z "$(git -C "${D}" config --local --get core.gitProxy || true)"
assert "worktree config removed" test ! -e "${D}/.git/config.worktree"
assert "worktree config extension removed" test -z "$(git -C "${D}" config --local --get extensions.worktreeConfig || true)"
assert "agent changes committed" test "$(git -C "${D}" log -1 --format=%s)" = "run-test-run"
echo ""

echo "Test 3: Symlinked git metadata is rejected without losing history"
D="${WORKSPACE}/test3"
REAL_GIT="${WORKSPACE}/test3-git"
mkdir -p "${D}"
git -C "${D}" init -q
git -C "${D}" config user.email "test@example.com"
git -C "${D}" config user.name "Test"
touch "${D}/initial"
git -C "${D}" add initial
git -C "${D}" commit -qm initial
INITIAL_COMMIT="$(git -C "${D}" rev-parse HEAD)"
mv "${D}/.git" "${REAL_GIT}"
ln -s "${REAL_GIT}" "${D}/.git"
if run_script "${D}" >/dev/null; then
SYMLINK_REJECTED=false
else
SYMLINK_REJECTED=true
fi
assert "symlinked metadata was rejected" test "${SYMLINK_REJECTED}" = true
assert "symlinked metadata was not replaced" test -L "${D}/.git"
assert "existing history was preserved" test "$(git -C "${D}" rev-parse HEAD)" = "${INITIAL_COMMIT}"
echo ""

echo "Tests passed: ${TESTS_PASSED}"
echo "Tests failed: ${TESTS_FAILED}"

if [ "${TESTS_FAILED}" -gt 0 ]; then
exit 1
fi

echo "✓ All tests passed!"
Loading