fix: refuse restore targets that would wipe the runner#27
Merged
Conversation
jeffwall-curlewlabs
added a commit
that referenced
this pull request
May 25, 2026
…guard fix: refuse restore targets that would wipe the runner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
cache-restore.shperforms an unconditionalrm -rf "$path_to_cache"on every stale-marker or missing-marker re-sync. A misconfigured caller whose workflow YAML evaluatespath:to/,$HOME, an empty/whitespace string, or a parent of the runner's workspace can therefore wipe large parts of the self-hosted machine. The caller does not have to be malicious — an unsetenv.TOOL_CACHEexpanding to empty, or a typo'd${{ runner.workspace }}that resolves to a parent directory, is enough. Before this change there was no up-front validation: the rm fires as soon as the Phase-2 rsync starts.Changed behavior
Add an up-front validation block that runs before any filesystem side effect (including
--checkmode, so the error shape stays consistent across Phase 1 and Phase 2). The guard rejects and exits 2 if the path is:/,/., or/..;$HOME,$RUNNER_WORKSPACE, or$GITHUB_WORKSPACE(whichever are set in the environment).Ancestor detection normalizes trailing slashes on both the target and the danger path so
/foo/and/foohash-match/foo/baridentically. The error message names the specific danger variable so a confused caller sees which env var was protecting them. Empty-string paths still hit the pre-existing empty-arg check and exit 1; that exit code is preserved for backwards compatibility. New safety rejections use exit 2 so callers can distinguish "missing arg" from "dangerous arg".Invariants / risks
/tmp/...orrunner.tool_cachepath sees the same cache-hit/miss behavior as before.--checkalso refuses, which is the right thing — a dangerous path is dangerous regardless of which phase is evaluating it, and rejecting in Phase 1 also prevents Phase 2 from acquiring the mutex for a doomed target.HOME,RUNNER_WORKSPACE,GITHUB_WORKSPACE). A self-hosted runner where those vars are unset would still be protected from the trivially-unsafe targets (/,/.,/..) but not from an ancestor of some custom directory outside the runner's knowledge. That is an acceptable limit — the invariant is "protect what the runner tells us to protect," not "perfectly infer every user-specific danger path."Verification
shellcheck lib/cache-restore.sh: clean.actionlint .github/workflows/ci.yml: clean.system root,must be absolute,HOME,GITHUB_WORKSPACE,RUNNER_WORKSPACE— so a future regression that widens or narrows the error shape surfaces in CI./tmp/guard-safe-siblingwith no env vars set still falls through toCache missand exit 0.