Skip to content

gh-aw: cache-memory restores an attacker-controlled .git/config and executes a git filter driver on the runner host

High
pelikhan published GHSA-gh77-fhfh-2mc5 Aug 16, 2026

Package

actions github/gh-aw (GitHub Actions)

Affected versions

<= 0.86.2

Patched versions

v0.87.0

Description

Summary

gh-aw already treats an attacker-written .git/ as a threat.
setup_cache_memory_git.sh:143-147:

Git hook files under .git/hooks/ are preserved in the cache but are NOT
tracked by git (git add -A ignores .git/). A compromised agent run could
write executable hooks (e.g. post-checkout, post-merge) that would be restored
from cache and executed on the host runner before the AWF sandbox is
established. Remove all non-sample hook files immediately after cache restore
to prevent this.

The mitigation is implemented and it works. It covers hooks only. .git/config
is restored from the same cache, is excluded from every sanitization pass, and
can define a filter driver: a shell command git runs during checkout, merge and
add. The script runs checkout at :185 and merge at :211.

The result is that the attacker capability the hook cleanup is built to contain
also gives command execution on the runner host, outside the AWF sandbox, with
no execute bit required. It persists across subsequent restores from that cache
namespace until the entry ages out or is replaced.

Verified on main at commit b2ef1f3 (2026-08-12). First verified at bdfdb8e
(2026-06-24, ~v0.80.9). Every line referenced below is unchanged across that
range.

Details

Locations, in actions/setup/sh/setup_cache_memory_git.sh unless noted:

  • :143-150 hook neutralization: the comment above, and
    find .git/hooks -type f ! -name '*.sample' -delete
  • :77, :173 git config core.hooksPath /dev/null
  • :185 git checkout -q "$INTEGRITY"
  • :211 git merge "$level" -X theirs --no-edit -m "merge-from-$level" -q
  • :234, :240, :283 every sanitization pass is scoped
    find . -not -path './.git/*' …
  • actions/setup/sh/commit_cache_memory_git.sh:~40 git add -A on save
  • actions/setup/js/validate_memory_files.cjs:51-53 post-agent validation skips
    .git
  • pkg/workflow/cache_integrity.go:188-223 cache key construction
  • pkg/workflow/cache.go:467-490 restore-key derivation

Seeding-side locations (see "Seeding" under Impact):

  • pkg/workflow/claude_tools.go:396-408 unscoped Write appended whenever
    safe-outputs is declared; :260-274 the scoped Write(<cacheDir>/*) grants
    the guard there fails to see
  • pkg/workflow/codex_engine.go:266-270 --dangerously-bypass-approvals-and-sandbox
    when the firewall is enabled; :284 features.shell_tool=false
  • pkg/workflow/codex_engine.go:67, pkg/workflow/agentic_engine.go:150-154
    shell-only scope of the bash-disable mapping
  • .github/workflows/ai-moderator.lock.yml:527, :529, :537, :899 step
    order; :926 --mount /tmp/gh-aw:/tmp/gh-aw:rw; :927 the codex invocation
  • .github/workflows/ai-moderator.md:216 the instruction to write into the
    cache-memory directory under bash: false

The gap. The three sanitization passes at :234 (symlink delete), :240
(execute-bit strip) and :283 (extension allowlist) are all scoped
-not -path './.git/*'. They are working-tree controls, so the exclusion is
deliberate, but it leaves .git/config restored byte-for-byte and outside the
GH_AW_ALLOWED_EXTENSIONS allowlist that covers everything else the agent
writes. core.hooksPath=/dev/null closes hooks and nothing else: a filter driver
is invoked through the config, not through the hooks path. Git runs the driver
via sh -c, so the file it operates on needs no execute bit, and the chmod a-x
at :240 does not help. That is why this applies on Linux GitHub-hosted runners.

The primitive. A prior run plants:

# .git/config
[filter "p"]
    smudge = sh -c '<command>'
# .git/info/attributes
*       filter=p

Both paths are inside .git/. On the next run, git checkout -q "$INTEGRITY"
(:185) materializes a file matching the attributes pattern, git invokes the
smudge driver, and the command runs on the host.

git add -A ignores .git/, so neither file is committed to an integrity
branch. They are not versioned content. They are cached filesystem state riding
in the tarball, which is why the hook files needed a dedicated cleanup step.

How the state crosses runs. The cache key is
memory-{integrity}-{policyHash}-{workflowID}-${{ github.run_id }}
(cache_integrity.go:210-223). The restore-keys list is that key with the run-id
suffix stripped, so a prefix (cache.go:467-481). Each run writes a new
immutable entry and restores the most recent prior entry from the same namespace.
Run N poisons, run N+1 detonates. No cache-key collision, race or eviction timing
is involved. The key derivation here is read from your code rather than observed
on live infrastructure; T13 covers the carriage step by archiving and restoring
the directory to a fresh path.

The integrity level and policy hash are part of the key, so a run at
min-integrity: none and a run at approved use different namespaces and cannot
poison each other. That separation holds, and I am not claiming otherwise. What
the poisoned config crosses is every subsequent run at the same integrity level,
and under scope: repo every workflow at that level, since
buildCacheRestoreKeys (cache.go:483-488) adds a second restore-key with the
workflow ID trimmed.

The boundary crossed is sandbox-to-host, not level-to-level. The
attacker-controlled config is executed by the host-side restore process, which
sits outside the AWF sandbox that contains the agent of that same workflow. On a
workflow triggered by issues or issue_comment at min-integrity: none, the
attacker reaches both the seeding run and the detonating run with no privileged
interaction, and the code that runs is outside that workflow's containment.

PoC

26 assertions driving gh-aw's unmodified setup_cache_memory_git.sh,
commit_cache_memory_git.sh and validate_memory_files.cjs against a poisoned
cache directory. All pass. The payload writes a marker file and records id -un.
No network access, no third-party system. The suite is attached and runs in one
command.

Environment: Linux x86_64, git 2.43.0, node 18.19.1, against the tree at
b2ef1f3. This was a WSL2 Ubuntu 24.04 container, not a GitHub-hosted runner —
I do not have one to test on. git 2.43.0 is the version shipped on
ubuntu-24.04 runner images, and nothing in the primitive depends on anything
below git and a POSIX shell. One caveat for anyone reproducing from a Windows
clone: CRLF line endings make the shipped scripts abort at set +o histexpand
before any of this is reached, which is an artifact of the checkout, not a
control.

Execution:

  • T1 With core.hooksPath=/dev/null set as at :77, a planted
    filter.p.smudge executes on git checkout. Hook neutralization does not
    cover filter drivers.
  • T2 The shipped script, with GH_AW_CACHE_DIR pointed at the poisoned repo
    and GH_AW_MIN_INTEGRITY=none: the script's own checkout at :185 materialized
    the file, the smudge driver ran on the host, and the marker was written. The
    script exited 0, and its 16 lines of output name neither the filter nor the
    payload — the test asserts that rather than my reading the log.
  • T11 The merge at :211 invokes the filter as well, independently of the
    checkout. The test leaves the repo already on the target branch so the checkout
    at :185 is a no-op, clears the marker after that setup (the setup runs with
    the filter live and would otherwise be misattributed to the merge), asserts the
    marker is clear going in, and asserts HEAD actually advanced — so a merge
    genuinely happened. Checkout is not the only sink.

Controls that do not stop it:

  • T3 A planted .git/hooks/post-checkout is neutralized correctly. The
    existing control works as designed; this finding covers what it does not reach.
  • T4 With GH_AW_ALLOWED_EXTENSIONS=.json, which is ai-moderator's setting,
    .git/config and .git/info/attributes survive all three passes and the
    payload fires.
  • T8 git fsck --connectivity-only at :159 passes on the poisoned repo, so
    the recovery path that would rm -rf .git never runs.
  • T9 commit_cache_memory_git.sh leaves the poison intact at save time, and
    git ls-files confirms nothing under .git/ is tracked.
  • T12 The post-agent gate misses it too. "Validate cache-memory file types"
    runs between "Commit cache-memory changes" and "Upload agent artifacts".
    Driving the real validate_memory_files.cjs over the poisoned directory with
    ai-moderator's [".json"] allowlist returns
    {"valid":true,"invalidFiles":[]}. It reports no .git path at all, because
    of the skip at validate_memory_files.cjs:51-53
    (// Skip .git directory — it is git metadata used for integrity branching).

With T4 that is four controls excluding .git/: three restore-side sanitization
passes and the save-side validation gate. An agent write into .git/ is not
inspected at any point in the cache lifecycle, in either direction.

Persistence:

  • T5 Fires on three successive restores from the same poisoned state.
  • T6 Fires on a checkout of a different integrity branch than the one it was
    planted from. The config is repo-global, not per-branch content.
  • T13 The poison survives an archive round-trip. Every test above points
    GH_AW_CACHE_DIR at an already-poisoned directory in place, which proves
    detonation-given-poison but not carriage. T13 tars the directory, deletes it,
    extracts to a different path and runs the shipped script there: .git/config
    comes back intact and the payload fires. This models what actions/cache does;
    I have not observed a real cache save/restore cycle on GitHub's infrastructure,
    so the tar step stands in for it.

Seeding:

  • T14 A plain file write is sufficient — no shell and no execute bit. The
    seeding is done with node's fs.writeFileSync/appendFileSync, the test
    asserts neither written file is executable, and the payload still fires. This
    bounds what the seeding run needs to a tool that writes a file at a chosen
    relative path inside the cache-memory folder. It does not demonstrate a
    specific gh-aw agent tool reaching .git/config; see "Seeding" under Impact
    for what I am and am not claiming.

Bounds:

  • T7 A tracked working-tree .gitattributes with no corresponding
    .git/config filter definition does not execute. A write inside .git/ is
    required, and this cannot be reached through tracked content alone. This is
    why I scored AC:H and not AC:L.
  • T10 A malformed payload is non-fatal: checkout still exits 0 and writes the
    content unfiltered. For anyone reproducing this, ; and # start comments
    inside a git config value, so a naive payload is truncated silently. A
    well-formed one produces no error output.

Impact

Command execution on the runner host during the cache-restore step, outside the
AWF sandbox and its egress firewall.

The containment that bounds a steered agent does not apply here. The network
allowlist, the gvisor sandbox and --exclude-env all wrap the agent step. This
runs in the restore step that precedes it.

Seeding. The attack requires a run whose agent writes into its cache-memory
.git/. I am not reporting that as a separate vulnerability, but it is the
precondition the whole chain rests on, so I went and established it from your own
shipped artifacts rather than assuming it.

T14 bounds the capability needed: a file write at a chosen path inside the
cache-memory folder. No shell, no execute bit, no chmod. That matters because
tools.bash: false is the constraint people reach for first, and it does not
close this. The rest is whether your configurations actually hand an agent that
capability with .git/ in reach. They do, on both engines.

Claude engine — the grant is unscoped, and your compiler emits it. Compile any
workflow that declares safe-outputs and the allowed-tools list contains a bare
Write with no path argument. appendSafeOutputsTools
(pkg/workflow/claude_tools.go:396-408) appends it whenever the exact string
Write is absent; the scoped cache grants added by appendCacheMemoryTools
(:260-274) are the distinct strings Write(<cacheDir>/*), so the
slices.Contains(allowedTools, "Write") guard never sees them and the unscoped
entry is appended anyway. The comment there concedes the scoped grant is not
honored reliably and cites issue #244. I compiled a minimal workflow —
engine: claude, tools.bash: false, cache-memory, one add-labels
safe-output — with gh-aw compile at b2ef1f3. The emitted
--allowed-tools contains:

...,NotebookEdit,NotebookRead,Read,Read(/tmp/*),...,Task,TodoWrite,
Write,Write(/tmp/*),Write(/tmp/gh-aw/agent/*),Write(/tmp/gh-aw/cache-memory/*),...

Bare Write, alongside bare Edit and MultiEdit, under
--permission-mode acceptEdits. bash: false is set and changes nothing here.
An agent so configured can write any absolute path, including
/tmp/gh-aw/cache-memory/.git/config. This is reproducible in about a minute
with your own compiler.

Codex engine — ai-moderator specifically. In
.github/workflows/ai-moderator.lock.yml: the cache-memory directory is created
(:527), restored (:529) and given its git repository (:537) before the agent
step (:899), so .git/ is present on disk while the agent runs. The AWF
invocation at :926 mounts --mount /tmp/gh-aw:/tmp/gh-aw:rw. That is
deliberate and specific: ${RUNNER_TEMP}/gh-aw is mounted :ro on the same
command line. The agent then runs

codex exec ... -c features.shell_tool=false \
    --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check

Codex's own sandbox and approval policy are off entirely — gh-aw delegates
containment to the AWF container, and that container mounts the directory
read-write. features.shell_tool=false removes the shell tool only;
codex_engine.go:67 and agentic_engine.go:150-154 describe it in exactly those
terms ("can fully refuse shell execution"). File writing is not the shell tool.

Three things make this broader than one workflow's configuration:

  • The bypass is the default posture, not an ai-moderator quirk. In
    codex_engine.go:266-270 the swap is an unconditional boolean: firewall
    enabled replaces --sandbox workspace-write ... approval_policy="never" with
    --dangerously-bypass-approvals-and-sandbox. isFirewallEnabled
    (firewall.go:37-60) returns true whenever the sandbox is enabled. So enabling
    gh-aw's sandbox is what disables Codex's own, and the two are mutually
    exclusive by construction. Every firewall-enabled Codex workflow is in this
    state.
  • Codex gets no per-path write scoping from gh-aw at all. CacheMemoryConfig is
    referenced in claude_tools.go but appears zero times in codex_engine.go;
    there is no Codex analogue of Claude's Write(<cacheDir>/*), and
    codex_harness.cjs contains no path validation either. The sandbox flag gh-aw
    disables is the only write-path control the Codex path ever had.
  • No write-time guard on .git/ exists anywhere in the tree. Searching the
    non-test .go and .cjs sources for denylist/blocklist/path-validation logic
    turns up .git handling in exactly four places: the hook cleanup, the
    -not -path sanitization scoping, validate_memory_files.cjs:51-53, and the
    safe_outputs_handlers.cjs / upload_artifact.cjs path rejectors — which do
    reject .git components, but govern safe-output and artifact-upload paths, not
    what an agent's own file-write tool may target mid-run. Every one of them is
    read-side or post-hoc.

That last point is worth stating positively, because you have already decided
this question in the adjacent subsystem. ADR-28486, "Protect Any Top-Level
Dot-Folder in Safe Outputs Handlers" (2026-04-25), replaced a hardcoded list of
protected dot-directories with a general deny-by-default rule
(protect_top_level_dot_folders: true, runtime checkForTopLevelDotFolders()),
explicitly rejecting the opt-in alternative because it "provides no safety net
against accidental or adversarial writes into new tool-configuration directories
that are not yet on any team's radar." That is the same threat model this report
describes, reasoned to the same conclusion, and implemented — for the files an
agent writes through safe outputs. The cache-memory directory an agent writes
directly is not covered by it, and .git/ is the dot-folder that happens to
carry an execution surface.

That it still writes files is not an inference: ai-moderator's own instructions
require it. ai-moderator.md:216 tells the agent to "Write the updated array
back to /tmp/gh-aw/cache-memory/spam-log.json", with bash: false set. A
working, path-unrestricted file-write capability therefore exists in that exact
configuration, because the workflow's core function depends on it.

The only content restriction on that directory is
allowed-extensions: [".json"], and it is enforced after the fact by
validate_memory_files.cjs, which T12 shows skips .git entirely and never
reports a path under it.

What I did not do: drive a live model into emitting the write. My PoC plants the
bytes directly, and demonstrating the model step needs API credentials and a real
workflow run, which I do not have and will not test against your infrastructure.
One related unknown I want to name rather than gloss: whether the upstream
codex CLI itself refuses a write to a .git/-component path independently of
the sandbox flag. That binary is outside your tree, so I could not settle it from
your source. If it does refuse, the Codex-engine reachability argument weakens to
the Claude-engine one, which is unaffected — the bare Write grant is emitted by
your compiler and carries no path restriction of any kind.
So the honest statement is that the capability is granted by your shipped
configurations and the path is unrestricted, both shown above; what remains
unproven is only the prompt-injection step that steers a model already holding
that capability. I have kept AC:H on that basis rather than arguing the
complexity down.

One correction to an earlier draft of this report, in case it reached you: I
previously quoted the cache-memory prompt as telling the agent "You can freely
read from and write to any files in this folder." That line lives in
pkg/workflow/prompts/cache_memory_prompt.md and in
actions/setup/md/repo_memory_prompt.md, which is a different feature
(repo-memory). Neither is what a cache-memory run sees. The lock's
GH_AW_PROMPT_CONFIG references {"file":"cache_memory_prompt.md"}, which
resolves to actions/setup/md/cache_memory_prompt.md, and that file says only
"Persistent read/write storage across workflow runs via Actions cache. Last write
wins." The pkg/workflow/prompts/ copy carries no go:embed directive — the
only two files in that directory that are embedded are
checkouts_no_credentials_warning.md and github_context_prompt.md — so it is
not compiled into anything. The argument above does not rest on that quote.

Given seeding, detonation is deterministic.

Reachability on this repository. .github/workflows/ai-moderator.md enables
cache-memory (key: spam-tracking-${{ github.repository_owner }}, compiled to
memory-none-9f0b69b3-… in the lock) on a workflow that also sets roles: all,
min-integrity: none and threat-detection: false against issues,
issue_comment and fork pull_request. Seeding and detonation are the same
workflow at the same integrity level, reachable by any GitHub account. I reported
the activation side of that configuration separately as GHSA-2cwf-x2h8-mqj5.
tools.bash: false is set there, and as shown under Seeding it does not
constrain this: the shell tool is disabled, the file-write tool is not, and the
directory is mounted read-write with Codex's own sandbox bypassed. The restore
path is the same for any adopter that grants file writes.

Reach. A gh search code sweep in June 2026 returned ~1,136 repositories
using cache-memory in a gh-aw workflow, including Azure/azure-sdk-for-js,
Z3Prover/z3, dotnet/machinelearning, dotnet/macios, microsoft/aspire,
microsoft/vstest and pulumi/pulumi-kubernetes-operator. I have not re-run it
since, and it was a sweep rather than an exhaustive enumeration, so treat the
figure as indicative.

Scope. I checked whether this is a generic Actions problem. Cache poisoning
as a class is well known, but it normally uses cached binaries or dependencies as
the sink. Caching a managed .git/ including config and attributes, then running
git against it on the host at restore with hooks-only neutralization, is specific
to gh-aw. Of ~22 workflow files matching actions/cache plus .git/objects, all
were object-only or incidental, and none cached .git/config.

Scoring. S:C is the one metric here I would call arguable, so I will make
the case and also give you the alternative. My reading: the vulnerable component
is gh-aw's cache-restore step and the impacted component is the runner host,
which lies outside the AWF sandbox that gh-aw establishes as the containment
authority for agent-influenced execution. Content authored under one authority
executes under another, which is the sandbox-escape case in the CVSS v3.1
definition.

The counter-argument, which I think is reasonable: the restore step was never
inside the sandbox to begin with, so nothing is escaped at the moment of
execution — an always-unsandboxed host script simply consumes attacker-controlled
data. The separation is temporal rather than a boundary crossed at execution
time. On that reading S:U gives
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H = 7.5, still High. Nothing else in
this report depends on which of the two you pick.

AC:H:
the agent must be steered into the .git/ write during the seeding run, and a
later run must restore the same cache namespace. T7 tests whether the first
condition can be dropped and shows it cannot. I have kept AC:H even though the
Seeding section shows the write capability is granted by configuration rather
than assumed, because steering a model into emitting the specific bytes is still
required and I have not demonstrated that step. If you conclude that step is
routine for an injectable workflow, AC:L gives 9.9 with S:C or 8.8 with
S:U; I am not claiming those. PR:L: the attack needs whatever
privilege triggers the workflow, which for a gated workflow is an authenticated
account with some level of access. The score covers the generic vulnerability.
The ai-moderator configuration above is triggerable by any GitHub account; I
present that as deployment reach rather than a second score.

Suggested fix

Do not trust anything under .git/ on restore. The issue is not the filter
driver specifically. Git metadata is carried across the trust boundary, and git's
config-driven execution surface is too large to enumerate safely.

Preferred: do not carry the metadata across. rm -rf .git and re-initialize
the integrity branches from the restored working tree, or reset .git/config and
.git/info/attributes to known-good content before any git operation runs. The
script already does this on its corruption paths (:162, :176, :190), so the
mechanism exists and is simply not applied on the normal restore path. This
closes the class and does not require maintaining a list of git's executable
config keys.

Fallback, if the metadata must be preserved. Run the restore-time git
operations with the execution drivers disabled (-c filter.<*>.smudge=,
-c filter.<*>.clean=, -c diff.<*>.textconv=, -c core.fsmonitor=false) and
reject include/includeIf directives in the restored config. This is the
weaker option. I proved the filter-driver path end to end and list the other keys
because a fix scoped to filter.* would leave the class open, not because I
verified each one.

Cover both call sites. T11 shows the merge at :211 invokes the filter
independently of the checkout at :185, so guarding only the checkout leaves the
path open.

Consider covering the write side too. The fixes above all sit on the restore
path, which is enough to close this finding. But the reason .git/ is reachable
at all is that nothing constrains where an agent's file-write tool may point
inside the cache-memory directory. ADR-28486 already settled the principle for
safe outputs and shipped checkForTopLevelDotFolders(); applying the same
deny-by-default rule to cache-memory writes would remove the seeding step rather
than the detonation step, and the mechanism already exists.

:143-150 identified the right threat and closed one instance of it. The control
needs to cover the directory, not one filename pattern.

Coordinated disclosure

I have not opened any public issue or PR and have not tested any third-party
repository. The suite ran locally against my own poisoned repository and gh-aw's
own shipped scripts. No third-party repo or GitHub system was touched. I will
hold public disclosure until a fix ships or we agree on a window (90 days as a
default). I understand open-source repos are outside bug-bounty scope and am not
requesting a bounty; I would appreciate credit if appropriate. I can share the
PoC suite and open a PR.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

External Control of System or Configuration Setting

One or more system settings or configuration elements can be externally controlled by a user. Learn more on MITRE.

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Protection Mechanism Failure

The product does not use or incorrectly uses a protection mechanism that provides sufficient defense against directed attacks against the product. Learn more on MITRE.

Inclusion of Functionality from Untrusted Control Sphere

The product imports, requires, or includes executable functionality (such as a library) from a source that is outside of the intended control sphere. Learn more on MITRE.

Credits