fix(run): resolve a run's cwd to one path spelling - #273
Merged
Conversation
POSIX gives this away for free: `getcwd` resolves symlinks, so a Unix process and everything it spawns already agree on how the working directory is spelled. Windows makes no such promise — it hands back whatever spelling the cwd was set with — and one directory there has several valid names: an 8.3 short name, a junction, a `subst` drive, a redirected profile. Nothing forced the two sides of the write guard's comparison onto one of them. Measured, spawning each tool the way a harness is spawned: node's `process.cwd()`, node's `realpathSync`, and `cmd`'s `cd` all echo the alias back, while git prints the resolved name for every path it emits. Both spellings are therefore reachable from inside a single task env, and `is_under` compares strings. Driving the hook with a short-form root, a write relative to a long-form cwd is denied, as is an absolute long-form target under the env — legitimate writes, refused. A genuine escape is still denied, so the guard never got weaker, only falsely strict. Every task env is a git repo, which makes `rev-parse --show-toplevel` a one-step route to the refused spelling. Resolve once, where the run's roots are derived, rather than at either end of the comparison: canonicalising only the marker inverts the failure instead of removing it, denying the same write when the cwd is the alias. Every path in a `RunContext` now shares one spelling, which makes it a property of the struct rather than of the one field someone remembered. Resolution walks up to the deepest ancestor that exists and re-attaches the rest, because a run names directories before it creates them and the alias always lives in an ancestor, never in the leaf. The cli fixtures that built roots with a bare `fs::canonicalize` now use the helper that strips the verbatim prefix. They had been comparing against `\?\` paths, a spelling no agent ever produces and one the CLI no longer emits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The problem
POSIX gives this away for free:
getcwdresolves symlinks, so a Unix process and everything it spawns already agree on how the working directory is spelled. Windows makes no such promise — it hands back whatever spelling the cwd was set with — and one directory there has several valid names: an 8.3 short name (RUNNER~1), a junction, asubstdrive, a redirected profile.Nothing forced the two sides of the write guard's comparison onto one of them.
Spawning each tool the way a harness is spawned (
CreateProcesswith an explicit short-form cwd — a shell in between contaminates the result, sinceSet-Locationexpands):process.cwd(), nodefs.realpathSync,cmd /c cdfs::canonicalize, PowerShellSet-LocationBoth spellings are reachable from inside a single task env, and
is_undercompares strings. Driving the real hook (eval-magic guard <marker>) with a short-form allowed root:The guard never got weaker — only falsely strict, and only on legitimate in-sandbox writes. Every task env is a git repo, so
git rev-parse --show-toplevelis a one-step route to the refused spelling.The fix
Resolve once, where a run's roots are derived, rather than at either end of the comparison. Canonicalising only the guard marker inverts the failure instead of removing it: with a resolved root and the alias the harness actually reports as its cwd, the same legitimate write is denied.
detect_run_contextis that seam — every root descends from its cwd.absolutizeroutes through the same helper, so--workspace-dircan't reintroduce the split as a second entrance. One spelling becomes a property of the wholeRunContextrather than of the one field someone remembered.core::fs::real_pathresolves the deepest existing ancestor and re-attaches the rest, because a run names directories before it creates them and the alias always lives in an ancestor, never in the leaf.Test fixture change worth a look
Several
tests/clifixtures built their roots with a barefs::canonicalize, which on Windows returns a\?\verbatim path — a spelling no agent ever produces. They passed only because the old lexical code handed the prefix through untouched on both sides of the comparison. They now use theresolvedhelper that strips it.Verification
Run twice on Windows — once normally, once with
%TEMP%pointed at an 8.3 short name, which reproduces the GitHub runner'sC:\Users\RUNNER~1\...:%TEMP%%TEMP%--test run--test cli--libThe
--libfailures are pre-existing ondevand unrelated: cleandevunder the identical 8.3%TEMP%gives the same four (829 / 4 — the delta is the new tests here). They are the Windows jq CRLF bug and the long-path measurement, both already fixed onci/windows-job. The pre-push hook was skipped for that reason; it should pass again once both branches land.Notes
Test suite (windows-latest)check on ci: run the suite on windows-latest #272, whose sole failure was this. That branch needs a rebase once this merges.🤖 Generated with Claude Code