Repository navigation
file.Glob and walkDir's fallback escape the sandboxed root #687
Description
Epic: #530
Found by: #677's sandboxed claim pass, 2026-08-26
pkg/fsroot exists so provider I/O is bounded by a kernel-enforced root: "All provider I/O flows through the
[Dir] interface" (pkg/fsroot/fsroot.go:6). Annotating methods with +devlore:claim=sandboxed found three
places where it does not.
1. file.Glob globs the raw filesystem
pkg/op/provider/file/provider.go:1361:
matches, err := filepath.Glob(pattern)filepath.Glob resolves against the process working directory, not the session root. A pattern reaching upward
matches outside the root, and nothing stops it.
2. walkDir falls back to an unsandboxed traversal
pkg/op/provider/file/provider.go:2237 walks the scoped root correctly:
return fs.WalkDir(osRoot.FS(), relRoot, func(relPath string, d fs.DirEntry, walkDirErr error) error {But :2241 is the other branch:
return filepath.WalkDir(absoluteRoot, walkFn)So file.Find and file.WalkTree are conditionally sandboxed — bounded when a scoped root exists, unbounded
when one does not. A conditional guarantee is not one, which is why all three methods were withheld from the
claim.
3. archive and encryption do no sandboxed I/O at all
Measured across production files, 2026-08-26:
| Provider | Direct os.* I/O calls |
fsroot use |
|---|---|---|
archive |
45 — os.ReadFile 13, os.MkdirAll 10, os.Create 9, os.Stat 6, os.WriteFile 4, os.Readlink 3, os.Open 3 |
imports it, uses root.NewPath ×3 for path construction only |
encryption |
16 — os.WriteFile 7, os.Stat 6, os.ReadFile 3 |
none |
archive importing fsroot and using it only to build paths is the most misleading shape of the three: the
import suggests confinement that the I/O does not have.
archive.extract matters most here. Extraction writes attacker-influenced paths from an archive; that is the
canonical zip-slip surface, and os.Create on a path derived from an archive entry is exactly what a sandboxed
root exists to prevent. 3.5.1-archive-provider.md:278 claims "Layer 3 — enforcement: fsroot's os.Root
backstop, already in place" — the measurement above says it is not.
Why this was invisible
Nothing asserted the property, so nothing could contradict it. The claim system makes the assertion checkable:
these methods cannot carry +devlore:claim=sandboxed until their I/O routes through an fsroot.Dir, and once
#677's static check lands (step 22), a false claim fails the build.
Scope
Three separable fixes: route file.Glob through the root; remove walkDir's fallback or make the root
mandatory; convert archive and encryption to fsroot.Dir. The archive one carries the security argument and
should lead.
Activity
David-Noble-at-work commentedon Aug 26, 2026
Correction, 2026-08-26 — finding 3 is wrong and is withdrawn.
archiveandencryptionboth route theirI/O through an
fsroot.Dir.Bad measurement. I ran
grep -h ... | grep -v _test, which filters the matched strings, not the file list— so
-hsuppressed filenames and every hit from a_test.gofile was counted as production. The "45 directos.* calls" in archive and "16" in encryption were test-file counts.
Measured correctly, production files only:
os.*I/O callsarchiveos.occurrences are types and constants —os.FileMode,*os.File,os.ModeSymlinkencryptionroot.ReadFile/root.WriteFileatprovider.go:77,:90,:157,:169; itsos.references are allos.FileModeThat distinction — calls and function values are I/O, types and constants are not — is the rule #677 step 13
already encodes. I applied it to the plan and then failed to apply it to my own grep.
The zip-slip argument is withdrawn with it.
3.5.1-archive-provider.md:278's claim that theos.Rootbackstop is "already in place" appears accurate; I asserted otherwise on bad data.
What survives, verified by line
file.Glob—pkg/op/provider/file/provider.go:1387callsfilepath.Glob(pattern), which resolvesagainst the process working directory rather than the session root.
walkDir's fallback —pkg/op/provider/file/provider.go:2277isfilepath.WalkDir(absoluteRoot, walkFn), the branch taken when no scoped root exists.file.Findandfile.WalkTreereach it, so both areconditionally sandboxed — and a conditional guarantee is not one.
Both remain the reason those three methods were withheld from
+devlore:claim=sandboxed.Retitled and dropped to Severity:Medium: a real confinement gap in one provider's traversal, not a systemic one
across three.
[-]Provider I/O escapes the sandbox: file.Glob, walkDir's fallback, and archive/encryption entirely[/-][+]file.Glob and walkDir's fallback escape the sandboxed root[/+]David-Noble-at-work commentedon Aug 26, 2026
Both surviving findings are fixed.
make checkexits 0, 103 packages.1.
file.Globnow matches over the root's own filesystemfilepath.Globwas replaced byfs.Glob(root.FS(), relPattern). The root'sFS()isos.Root.FS(), somatching is kernel-confined and the three escapes are structurally impossible rather than merely unlikely:
honored verbatim
..cannot survive, becausefs.Globpatterns are slash-form and root-relative by constructionos.Rootrefuses the traversalA pattern resolving outside now returns
file.Glob: pattern %q resolves outside the session rootrather thanquietly answering with matches from elsewhere.
Proved by
TestGlob_RefusesPatternsOutsideTheRoot, which asserts refusal for an absolute path into anotherdirectory, a
../<sibling>/*pattern, and a bare../*.2.
walkDir's unconfined fallback is goneThat branch walked the real filesystem from an absolute path with nothing bounding it, so
file.Findandfile.WalkTreewere confined only when a root happened to exist. A missing root is now refused:file: no session root; a directory walk has no boundary to observe.The fallback was not load-bearing — all 103 packages pass without it, so nothing in the suite relied on
walking rootless.
Result
file.Find,file.Glob, andfile.WalkTreenow carry+devlore:claim=sandboxed, which they could not before.filereaches complete claim coverage: 4 deterministic (join,name,parent,root) and 21 sandboxed, over25 announced methods.
feat(op): providers claim what they guarantee, and a type-checked pas…
Providers claim what they guarantee, and a type-checked pass holds th…