Skip to content

fix: refuse restore targets that would wipe the runner#27

Merged
jeffwall-curlewlabs merged 0 commit into
mainfrom
fix/restore-target-safety-guard
Apr 16, 2026
Merged

fix: refuse restore targets that would wipe the runner#27
jeffwall-curlewlabs merged 0 commit into
mainfrom
fix/restore-target-safety-guard

Conversation

@jeffwall-curlewlabs

Copy link
Copy Markdown
Contributor

Problem

cache-restore.sh performs an unconditional rm -rf "$path_to_cache" on every stale-marker or missing-marker re-sync. A misconfigured caller whose workflow YAML evaluates path: 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 unset env.TOOL_CACHE expanding 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 --check mode, so the error shape stays consistent across Phase 1 and Phase 2). The guard rejects and exits 2 if the path is:

  • whitespace-only;
  • not absolute (relative paths resolve against this script's CWD, which varies between phases);
  • exactly /, /., or /..;
  • equal to or an ancestor of $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 /foo hash-match /foo/bar identically. 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

  • No behavior change for well-formed callers. The two failure shapes (empty-arg exit 1, dangerous-path exit 2) are both new-or-preserved; a caller passing a legitimate /tmp/... or runner.tool_cache path sees the same cache-hit/miss behavior as before.
  • Phase 1 and Phase 2 see the same rejection. Running up-front means --check also 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.
  • Env-var-indirect protection only. The guard protects paths the runner itself populates (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.
  • 10 new CI tests (one per rejection class plus a safe-sibling positive control). Each asserts exit 2 AND the specific diagnostic substring — system root, must be absolute, HOME, GITHUB_WORKSPACE, RUNNER_WORKSPACE — so a future regression that widens or narrows the error shape surfaces in CI.
  • Local smoke: every rejection case exits 2 with a specific reason; the safe sibling /tmp/guard-safe-sibling with no env vars set still falls through to Cache miss and exit 0.

@jeffwall-curlewlabs jeffwall-curlewlabs merged this pull request into main Apr 16, 2026
6 checks passed
@jeffwall-curlewlabs jeffwall-curlewlabs deleted the fix/restore-target-safety-guard branch April 16, 2026 23:15
jeffwall-curlewlabs added a commit that referenced this pull request May 25, 2026
…guard

fix: refuse restore targets that would wipe the runner
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant