Skip to content

fix: every file is written under a name nothing else holds - #63

Merged
donislawdev merged 1 commit into
mainfrom
security/write-path-claims
Sep 6, 2026
Merged

fix: every file is written under a name nothing else holds#63
donislawdev merged 1 commit into
mainfrom
security/write-path-claims

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

A run writes each file to a temporary name and renames it into place. Three of those temporary names were created in a way that follows a link, so a link left at one of them by somebody else sent the bytes outside the output directory - and the run still reported success.

SECURITY.md puts "a way to make the tool write or delete outside the directory it was given" first in scope, and says a path leaving the directory through a symbolic link is refused. That was true of the two reading commands and of the files a run produces. It was not true of the names those files are written under first.

Reproduced against a build of the previous tree

planted at result before
<manifest>.tfg-writing the manifest landed on a file outside the output directory, exit 0. verify then said "matches" and cleanup said "2 files removed", both exit 0
<recipe>.tfg-writing recipe fmt -w wrote the recipe onto another file and left the recipe itself as a link, exit 0
<manifest> an empty file appeared outside the output directory, exit 0

The first needs no race and no pid guessing: the name is fixed and nothing anywhere looked at it. The manifest name does not have to be the default one. On Windows none of this needs a privilege, because a hard link is enough - which is also why os.Lstat on its own does not close it. A hard link is an ordinary file to every question except the create.

The fix

core.CreateNew is the one door now, called by engine.writeOne, manifest.claimName, manifest.writeOver and core.writeWhole. It creates exclusively and believes a refusal only when os.Lstat finds an entry, so O_EXCL closes the hard link and Lstat closes the link that points at nothing.

The fallback that exists because O_EXCL misreports a path running through a reparse point on Windows is unchanged, and it is now reached only when Lstat agrees nothing is there. An output directory reached through a link keeps working, which is checked.

What it costs

  • 23 formats byte for byte identical, zero moved.
  • 48 refusals character for character across two commands, 96 identical.
  • No measurable cost on the write path: txt 4 kB x2000 median 2170 to 2143 ms, png 200 kB x240 866 to 849 ms, ranges overlapping with a canary of 27% and 19%.

Behaviour change worth knowing

A leftover .tfg-writing file from a run that was killed part way used to be written over in silence. It is now a refusal naming the file. That is in CHANGELOG.md under Security.

Guards

internal/guard/writeescape_test.go: four shapes of a held name (plain file, hard link, symbolic link to a file, symbolic link to nothing) across three writers, plus an AST guard that keeps file creation to the one door with a named exception list. Five mutations, all caught. The symbolic link cases skip loudly where the host refuses to create one - the hard link cases run everywhere.

🤖 Generated with Claude Code

Files go to a temporary name and are renamed into place. Three of those
temporary names were created in a way that follows a link, so a link left
at one of them sent the bytes outside the output directory while the run
reported success.

Reproduced against a build of the previous tree, in a scratch directory:

  a link at <manifest>.tfg-writing   the manifest landed on a file outside
                                     the output directory, exit 0, and then
                                     verify said "matches" and cleanup said
                                     "2 files removed", both exit 0
  a link at <recipe>.tfg-writing     recipe fmt -w wrote the recipe onto
                                     another file and left the recipe as a
                                     link, exit 0
  a link at <manifest>               an empty file appeared outside the
                                     output directory, exit 0

The first needs no race and no guessing: the name is fixed and nothing
looked at it. On Windows none of it needs a privilege, because a hard link
is enough - which is also why os.Lstat alone does not close it, since a
hard link is an ordinary file to every question but the create itself.

core.CreateNew is now the one door. It creates exclusively and believes a
refusal only when os.Lstat finds an entry, so O_EXCL closes the hard link
and Lstat closes the link that points at nothing. The fallback that exists
because O_EXCL misreports a path running through a reparse point on Windows
is unchanged, and an output directory reached through a link still works.

Measured: 23 formats byte for byte identical, 48 refusals character for
character, and no measurable cost on the write path - txt 4 kB x2000 median
2170 to 2143 ms and png 200 kB x240 866 to 849 ms, ranges overlapping with
a canary of 27% and 19%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 46e0aa1 into main Sep 6, 2026
18 checks passed
@donislawdev
donislawdev deleted the security/write-path-claims branch September 6, 2026 12:41
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