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.
Summary
gh-aw already treats an attacker-written
.git/as a threat.setup_cache_memory_git.sh:143-147:The mitigation is implemented and it works. It covers hooks only.
.git/configis 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
:185and 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
mainat commitb2ef1f3(2026-08-12). First verified atbdfdb8e(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.shunless noted::143-150hook neutralization: the comment above, andfind .git/hooks -type f ! -name '*.sample' -delete:77,:173git config core.hooksPath /dev/null:185git checkout -q "$INTEGRITY":211git merge "$level" -X theirs --no-edit -m "merge-from-$level" -q:234,:240,:283every sanitization pass is scopedfind . -not -path './.git/*' …actions/setup/sh/commit_cache_memory_git.sh:~40git add -Aon saveactions/setup/js/validate_memory_files.cjs:51-53post-agent validation skips.gitpkg/workflow/cache_integrity.go:188-223cache key constructionpkg/workflow/cache.go:467-490restore-key derivationSeeding-side locations (see "Seeding" under Impact):
pkg/workflow/claude_tools.go:396-408unscopedWriteappended wheneversafe-outputsis declared;:260-274the scopedWrite(<cacheDir>/*)grantsthe guard there fails to see
pkg/workflow/codex_engine.go:266-270--dangerously-bypass-approvals-and-sandboxwhen the firewall is enabled;
:284features.shell_tool=falsepkg/workflow/codex_engine.go:67,pkg/workflow/agentic_engine.go:150-154shell-only scope of the bash-disable mapping
.github/workflows/ai-moderator.lock.yml:527,:529,:537,:899steporder;
:926--mount /tmp/gh-aw:/tmp/gh-aw:rw;:927the codex invocation.github/workflows/ai-moderator.md:216the instruction to write into thecache-memory directory under
bash: falseThe 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 isdeliberate, but it leaves
.git/configrestored byte-for-byte and outside theGH_AW_ALLOWED_EXTENSIONSallowlist that covers everything else the agentwrites.
core.hooksPath=/dev/nullcloses hooks and nothing else: a filter driveris 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 thechmod a-xat
:240does not help. That is why this applies on Linux GitHub-hosted runners.The primitive. A prior run plants:
Both paths are inside
.git/. On the next run,git checkout -q "$INTEGRITY"(
:185) materializes a file matching the attributes pattern, git invokes thesmudge driver, and the command runs on the host.
git add -Aignores.git/, so neither file is committed to an integritybranch. 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-idsuffix stripped, so a prefix (
cache.go:467-481). Each run writes a newimmutable 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: noneand a run atapproveduse different namespaces and cannotpoison 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: repoevery workflow at that level, sincebuildCacheRestoreKeys(cache.go:483-488) adds a second restore-key with theworkflow 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
issuesorissue_commentatmin-integrity: none, theattacker 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.shandvalidate_memory_files.cjsagainst a poisonedcache 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.04runner images, and nothing in the primitive depends on anythingbelow 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 histexpandbefore any of this is reached, which is an artifact of the checkout, not a
control.
Execution:
core.hooksPath=/dev/nullset as at:77, a plantedfilter.p.smudgeexecutes ongit checkout. Hook neutralization does notcover filter drivers.
GH_AW_CACHE_DIRpointed at the poisoned repoand
GH_AW_MIN_INTEGRITY=none: the script's own checkout at:185materializedthe 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.
:211invokes the filter as well, independently of thecheckout. The test leaves the repo already on the target branch so the checkout
at
:185is a no-op, clears the marker after that setup (the setup runs withthe 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:
.git/hooks/post-checkoutis neutralized correctly. Theexisting control works as designed; this finding covers what it does not reach.
GH_AW_ALLOWED_EXTENSIONS=.json, which is ai-moderator's setting,.git/configand.git/info/attributessurvive all three passes and thepayload fires.
git fsck --connectivity-onlyat:159passes on the poisoned repo, sothe recovery path that would
rm -rf .gitnever runs.commit_cache_memory_git.shleaves the poison intact at save time, andgit ls-filesconfirms nothing under.git/is tracked.runs between "Commit cache-memory changes" and "Upload agent artifacts".
Driving the real
validate_memory_files.cjsover the poisoned directory withai-moderator's
[".json"]allowlist returns{"valid":true,"invalidFiles":[]}. It reports no.gitpath at all, becauseof 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 sanitizationpasses and the save-side validation gate. An agent write into
.git/is notinspected at any point in the cache lifecycle, in either direction.
Persistence:
planted from. The config is repo-global, not per-branch content.
GH_AW_CACHE_DIRat an already-poisoned directory in place, which provesdetonation-given-poison but not carriage. T13 tars the directory, deletes it,
extracts to a different path and runs the shipped script there:
.git/configcomes back intact and the payload fires. This models what
actions/cachedoes;I have not observed a real cache save/restore cycle on GitHub's infrastructure,
so the tar step stands in for it.
Seeding:
seeding is done with node's
fs.writeFileSync/appendFileSync, the testasserts 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 Impactfor what I am and am not claiming.
Bounds:
.gitattributeswith no corresponding.git/configfilter definition does not execute. A write inside.git/isrequired, and this cannot be reached through tracked content alone. This is
why I scored
AC:Hand notAC:L.content unfiltered. For anyone reproducing this,
;and#start commentsinside 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-envall wrap the agent step. Thisruns 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 theprecondition 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 becausetools.bash: falseis the constraint people reach for first, and it does notclose 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-outputsand the allowed-tools list contains a bareWritewith no path argument.appendSafeOutputsTools(
pkg/workflow/claude_tools.go:396-408) appends it whenever the exact stringWriteis absent; the scoped cache grants added byappendCacheMemoryTools(
:260-274) are the distinct stringsWrite(<cacheDir>/*), so theslices.Contains(allowedTools, "Write")guard never sees them and the unscopedentry 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, oneadd-labelssafe-output — with
gh-aw compileatb2ef1f3. The emitted--allowed-toolscontains:Bare
Write, alongside bareEditandMultiEdit, under--permission-mode acceptEdits.bash: falseis 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 minutewith 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 agentstep (
:899), so.git/is present on disk while the agent runs. The AWFinvocation at
:926mounts--mount /tmp/gh-aw:/tmp/gh-aw:rw. That isdeliberate and specific:
${RUNNER_TEMP}/gh-awis mounted:roon the samecommand line. The agent then runs
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=falseremoves the shell tool only;codex_engine.go:67andagentic_engine.go:150-154describe it in exactly thoseterms ("can fully refuse shell execution"). File writing is not the shell tool.
Three things make this broader than one workflow's configuration:
codex_engine.go:266-270the swap is an unconditional boolean: firewallenabled 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 enablinggh-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.
CacheMemoryConfigisreferenced in
claude_tools.gobut appears zero times incodex_engine.go;there is no Codex analogue of Claude's
Write(<cacheDir>/*), andcodex_harness.cjscontains no path validation either. The sandbox flag gh-awdisables is the only write-path control the Codex path ever had.
.git/exists anywhere in the tree. Searching thenon-test
.goand.cjssources for denylist/blocklist/path-validation logicturns up
.githandling in exactly four places: the hook cleanup, the-not -pathsanitization scoping,validate_memory_files.cjs:51-53, and thesafe_outputs_handlers.cjs/upload_artifact.cjspath rejectors — which doreject
.gitcomponents, but govern safe-output and artifact-upload paths, notwhat 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, runtimecheckForTopLevelDotFolders()),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 tocarry an execution surface.
That it still writes files is not an inference: ai-moderator's own instructions
require it.
ai-moderator.md:216tells the agent to "Write the updated arrayback to
/tmp/gh-aw/cache-memory/spam-log.json", withbash: falseset. Aworking, 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 byvalidate_memory_files.cjs, which T12 shows skips.gitentirely and neverreports 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
codexCLI itself refuses a write to a.git/-component path independently ofthe 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
Writegrant is emitted byyour 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:Hon that basis rather than arguing thecomplexity 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.mdand inactions/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_CONFIGreferences{"file":"cache_memory_prompt.md"}, whichresolves 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 nogo:embeddirective — theonly two files in that directory that are embedded are
checkouts_no_credentials_warning.mdandgithub_context_prompt.md— so it isnot 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.mdenablescache-memory (
key: spam-tracking-${{ github.repository_owner }}, compiled tomemory-none-9f0b69b3-…in the lock) on a workflow that also setsroles: all,min-integrity: noneandthreat-detection: falseagainstissues,issue_commentand forkpull_request. Seeding and detonation are the sameworkflow 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: falseis set there, and as shown under Seeding it does notconstrain 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 codesweep in June 2026 returned ~1,136 repositoriesusing 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 runninggit against it on the host at restore with hooks-only neutralization, is specific
to gh-aw. Of ~22 workflow files matching
actions/cacheplus.git/objects, allwere object-only or incidental, and none cached
.git/config.Scoring.
S:Cis the one metric here I would call arguable, so I will makethe 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:UgivesCVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H= 7.5, still High. Nothing else inthis 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 alater run must restore the same cache namespace. T7 tests whether the first
condition can be dropped and shows it cannot. I have kept
AC:Heven though theSeeding 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:Lgives 9.9 withS:Cor 8.8 withS:U; I am not claiming those.PR:L: the attack needs whateverprivilege 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-moderatorconfiguration above is triggerable by any GitHub account; Ipresent that as deployment reach rather than a second score.
Suggested fix
Do not trust anything under
.git/on restore. The issue is not the filterdriver 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 .gitand re-initializethe integrity branches from the restored working tree, or reset
.git/configand.git/info/attributesto known-good content before any git operation runs. Thescript already does this on its corruption paths (
:162,:176,:190), so themechanism 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) andreject
include/includeIfdirectives in the restored config. This is theweaker 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 Iverified each one.
Cover both call sites. T11 shows the merge at
:211invokes the filterindependently of the checkout at
:185, so guarding only the checkout leaves thepath 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 reachableat 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 samedeny-by-default rule to cache-memory writes would remove the seeding step rather
than the detonation step, and the mechanism already exists.
:143-150identified the right threat and closed one instance of it. The controlneeds 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.