Skip to content

file.Glob and walkDir's fallback escape the sandboxed root #687

Closed

Description

@David-Noble-at-work

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

  1. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    Correction, 2026-08-26 — finding 3 is wrong and is withdrawn. archive and encryption both route their
    I/O through an fsroot.Dir.

    Bad measurement. I ran grep -h ... | grep -v _test, which filters the matched strings, not the file list
    — so -h suppressed filenames and every hit from a _test.go file was counted as production. The "45 direct
    os.* calls" in archive and "16" in encryption were test-file counts.

    Measured correctly, production files only:

    Provider os.* I/O calls How it does I/O
    archive 0 its nine os. occurrences are types and constants — os.FileMode, *os.File, os.ModeSymlink
    encryption 0 root.ReadFile / root.WriteFile at provider.go:77, :90, :157, :169; its os. references are all os.FileMode

    That 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 the os.Root
    backstop is "already in place" appears accurate; I asserted otherwise on bad data.

    What survives, verified by line

    1. file.Glob — pkg/op/provider/file/provider.go:1387 calls filepath.Glob(pattern), which resolves
      against the process working directory rather than the session root.
    2. walkDir's fallback — pkg/op/provider/file/provider.go:2277 is filepath.WalkDir(absoluteRoot, walkFn), the branch taken when no scoped root exists. file.Find and file.WalkTree reach it, so both are
      conditionally 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.

  2. changed the title [-]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[/+] on Aug 26, 2026
  3. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    Both surviving findings are fixed. make check exits 0, 103 packages.

    1. file.Glob now matches over the root's own filesystem

    filepath.Glob was replaced by fs.Glob(root.FS(), relPattern). The root's FS() is os.Root.FS(), so
    matching is kernel-confined and the three escapes are structurally impossible rather than merely unlikely:

    • an absolute pattern is relativized against the root and refused when it lands outside, instead of being
      honored verbatim
    • .. cannot survive, because fs.Glob patterns are slash-form and root-relative by construction
    • a symlink out of the tree is not followed, because os.Root refuses the traversal

    A pattern resolving outside now returns file.Glob: pattern %q resolves outside the session root rather than
    quietly answering with matches from elsewhere.

    Proved by TestGlob_RefusesPatternsOutsideTheRoot, which asserts refusal for an absolute path into another
    directory, a ../<sibling>/* pattern, and a bare ../*.

    2. walkDir's unconfined fallback is gone

    return filepath.WalkDir(absoluteRoot, walkFn)   // removed

    That branch walked the real filesystem from an absolute path with nothing bounding it, so file.Find and
    file.WalkTree were 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, and file.WalkTree now carry +devlore:claim=sandboxed, which they could not before.
    file reaches complete claim coverage: 4 deterministic (join, name, parent, root) and 21 sandboxed, over
    25 announced methods.

  4. added 2 commits that reference this issue on Aug 26, 2026
    b202640
    ca7744e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Area:ArchitectureSystem architecture and designEpic:ExecutionGraphThe execution graph epicPriority:P2Scheduled, not urgentSeverity:MediumBroken with a workaround, or a narrow pathbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions