Skip to main content

PathFence

A path-confinement check for tools that let an AI agent (or any untrusted caller) read and write files, written and proven in Lean 4.

You give it the directories the caller may use, optionally directories inside them it may not use, and a requested path. It answers allow (with the resolved path, and the allowed root it is under), deny (with the resolved path) or reject (with a reason). Lean theorems state exactly when it allows, and a command-line tool makes the proven function usable from any language.

$ pathfence check --allow /tmp/allowed_dir -- /tmp/allowed_dir_malicious/secret ../x a/../b
{"input":"/tmp/allowed_dir_malicious/secret","decision":"deny","path":"/tmp/allowed_dir_malicious/secret"}
{"input":"../x","decision":"reject","reason":"escapes"}
{"input":"a/../b","decision":"allow","path":"/tmp/allowed_dir/b","root":"/tmp/allowed_dir","rel":"b"}

Status: v0.1.0, experimental. No human security review yet.

PathFence is not a sandbox. The decision is lexical. It looks only at the path string, never at the file system. A symlink, a race between the check and the open, a hard link, a mount or a case-insensitive file system can make an allowed path reach a file outside the roots, and an agent can create symlinks itself (by cloning a repository, extracting an archive, or running a shell command). Do not pass the returned path to a plain open(). Open rel beneath root with a confined call, as in Opening an allowed path, and read What it does not cover.

Why

Confinement checks are easy to get almost right. Published examples:

  • CVE-2025-53110 (MCP Filesystem server before 0.6.4 / 2025.7.01) and CVE-2025-54794 (Claude Code before 0.2.111): the check was a string-prefix test, so /tmp/allowed_dir_malicious passed as "inside" /tmp/allowed_dir.
  • CVE-2025-22873 (Go os.Root, new in 1.24; fixed in 1.24.3): Root.Open("../") opened the parent of the root.

PathFence compares paths segment by segment, and its theorems rule out both kinds of mistake for the decision it makes. PathFence/Examples.lean runs it on inputs in the style of these bugs at build time. (It does not reproduce the original programs.)

The rules

A policy has a base directory (where relative paths start), allowed roots and denied roots.

  1. Roots are taken literally and must be canonical: /, or / followed by names separated by single slashes, with no ., .., empty segment or trailing /. Anything else is refused, not normalised. Pass real directories (for example from realpath), not paths through symlinks.
  2. An empty input is rejected (empty), and so is an input containing NUL (nul).
  3. An absolute input is normalised lexically from /: empty pieces and . are dropped, and .. removes the previous segment (at / it does nothing, as in POSIX).
  4. A relative input is walked from the base with the beneath rule of Linux's openat2(RESOLVE_BENEATH): a .. that would leave the base is an error (escapes), even if later segments come back in. a/../../w/b with base /w is rejected.
  5. The resolved path is allowed when it is within some allowed root and within no denied root. "Within" compares whole segments: /tmp/a and /tmp/a/b are within /tmp/a; /tmp/ab is not.

Names are POSIX names: any characters except / and NUL. \, %2e, ... and non-ASCII names are ordinary names.

What is proven

All theorems are in PathFence/, checked by Lean v4.35.0, with no sorry and only Lean's standard axioms (propext, Classical.choice, Quot.sound; see scripts/audit.sh).

The table lists the 25 published theorems. Their statements are pinned in scripts/statements.expected in two forms:

  • the printed text;
  • a SHA-256 hash of the statement together with the raw terms of every PathFence definition it depends on, directly or indirectly. That includes implicit arguments, instances, and the constructors of inductive types.

The audit fails if a theorem is missing or renamed, if its statement changes, or if any of those definitions changes. The library's modules hold 138 theorem declarations in all, counting helper lemmas and the equation lemmas Lean generates. The audit checks the axioms of every declaration.

Theorem Statement
check_allow_iff check allows p iff the input resolves to p, p is within some allowed root, and p is within no denied root.
check_deny_iff, check_reject_iff It denies p iff the input resolves to p and p is not allowed; it rejects iff resolving fails, with the same reason.
resolve_rel_spec A non-empty relative input resolves to p iff p is its lexical walk from the base and every .. is taken strictly inside the base.
resolve_rel_within, check_allow_rel_within_base A resolved (or allowed) relative input is within the base.
resolve_empty, resolve_nul, resolve_abs Empty inputs and inputs with NUL are rejected; absolute inputs are normalised lexically from / (this restates the definition).
resolve_valid Every resolved segment is a real name: not empty, . or .., no / or NUL.
resolve_render Resolving a printed result gives the same path, so re-checking it gives the same decision. (This is about PathFence's lexical resolver, not about what the file system does with the string.)
within_iff_render For a root other than /, segment-wise "within" is the same as a string test on the printed paths: equal, or a prefix followed by /. This is the correct form of the string check that failed in the CVEs above.
allowedUnder_spec, allowedUnder_of_allow Every allowed path is reported with an allowed root root and a remainder rel such that root ++ rel is the path.
check_allow_of_add_deny, check_allow_add_allow Adding a denied root never allows more; adding an allowed root never allows less.
confine_allow_iff For a single root that is both the base and the only allowed root: allowed iff the input resolves within the root.
parseRoot_render, parseRoot_valid, Policy.make_valid A parsed root prints back to exactly its string, and consists of valid segments, as do all roots of a policy built with Policy.make.
walkB_spec, walkB_keeps_base, walkL_of_valid The beneath walk equals its declarative specification and never removes the base; walking valid segments appends them.
join_split, split_join Splitting on / and joining are inverse.

That the lexical normalisation of absolute inputs matches POSIX normpath is tested, not proven.

Where the proofs end

The statements are about the Lean functions Policy.check, resolve, Policy.allowedUnder and parseRoot on lists of characters. The CLI (Main.lean) calls them directly, and the rest of it is ordinary code covered by tests, not proofs: argument parsing, reading and decoding stdin, the conversion between String and List Char, JSON output and exit codes.

How it is checked

  • lake build checks the proofs and runs the #guard examples.

  • scripts/audit.sh:

    • before running Lake, checks lakefile.lean, lean-toolchain and lake-manifest.json against pinned SHA-256 hashes (a lakefile is code Lake runs). Any change to the build configuration therefore has to change the audit too, visibly;
    • rejects sorry, admit, native_decide, axioms, implemented_by/extern, partial/unsafe, #eval, #exit, initialize, kernel-skipping options and metaprogramming commands in the library source;
    • checks the compiled environment:
      • the library imports only Init and its own modules;
      • no library declaration has its compiled code replaced (implemented_by, extern);
      • every declaration (the list is generated, not maintained by hand) depends on no axiom outside the standard three;
      • the 25 published statements, and the definitions they depend on, match scripts/statements.expected;
    • replays every module through the kernel's own checker (leanchecker).

    CI checks the pins (audit.sh --pins) before anything builds. The audit does not protect its own inputs: scripts/audit.sh, scripts/Axioms.lean, scripts/statements.expected and the CI workflow. A change to them shows in the diff, but whoever can change them can change the audit.

    It does not check that Main.lean calls the library as documented; that is left to review and the tests.

  • tests/ (Python, standard library only) runs the built CLI against:

    • fixed cases, including the CVE-style inputs, invalid UTF-8, option-like paths and non-canonical roots;

    • a separate Python implementation of the rules above, on 7500 random inputs over five policies (one with overlapping roots), with root names and near misses (w, wx, w.) among the pieces. It follows the same rules, so it catches implementation mistakes, not mistakes in the rules;

    • posixpath.normpath for absolute inputs;

    • on Linux 5.6+, the kernel itself, on a directory tree with no symlinks. For 1500 random relative paths the test creates the directories, then calls openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS). On that tree:

      • PathFence allows a path exactly when the kernel opens it, and reports the directory the kernel opened (read back from /proc/self/fd), also when rel is opened beneath root;
      • it rejects with escapes exactly when the kernel refuses with EXDEV.

      This is agreement on a tested domain, not a proof that the rules are the kernel's. With symlinks, the two differ by design; see below.

    • the safe-open example against symlinks into a denied root and out of the workspace.

  • tests/mutants.py checks the tests themselves. It rebuilds the CLI with eleven plausible bugs and requires each one to make the suite fail. The bugs include the CVE-style string prefix, an off-by-one in the beneath rule, an ignored deny root, accepting the empty input, and a wrong root or rel.

Usage

Install the CLI from PyPI (once the first release is published):

python -m pip install --only-binary=:all: pathfence
pathfence check --allow /home/u/project --deny /home/u/project/.git -- src/main.rs

Wheels bundle the Lean executable: no Lean installation or Python runtime dependencies are needed. Supported platforms are Linux (x86-64 and ARM64) and macOS (Intel and Apple Silicon), with Python 3.10 or newer. Windows users can use WSL. The path rules remain POSIX-only on every platform. python -m pathfence also runs the CLI.

For source builds, install the pinned Lean toolchain. Release maintainers can follow Publishing PathFence.

Build with elan installed:

lake build
.lake/build/bin/pathfence check --allow /home/u/project --deny /home/u/project/.git -- src/main.rs
pathfence check (--allow DIR | --allow-all) ... [--deny DIR ...] [--base DIR] [--stdin] [-- PATH ...]
  • Paths go after --. Anything before -- that is not an option is an error, so a path such as --stdin can never be taken for an option.
  • --stdin reads one path per line, after any paths given after --.
    • The whole input is read before the first answer (see Performance).
    • Lines are split on \n only, so a \r stays part of the name.
  • --base defaults to the first --allow root.
  • --allow / is refused, so that an empty variable in --allow "$ROOT/" cannot allow every path. Use --allow-all if that is what you mean.
    • --allow-all counts as a root after all --allow roots, wherever it appears.
    • With no --allow root, the base for relative paths is /.
  • One JSON object per path, in order, in ASCII (other characters are \u escapes). Allowed results carry root and rel.
  • Exit status: 0 if at least one path was checked and every path is allowed, 1 if any is denied or rejected, and 2 for a usage error, including no paths at all.
  • Input that is not UTF-8 is rejected with reason encoding, instead of being checked as a different string. See Main.lean.

From Lean, depend on this package. Build the policy with PathFence.Policy.make, which refuses non-canonical roots, then call Policy.checkString and Policy.allowedUnder. Policies whose roots contain invalid segments are not checked for you: such a deny root would deny nothing.

Opening an allowed path

Open rel relative to a directory handle for root, refusing symlinks and any escape at open time. On Linux:

int rootfd = open(root, O_PATH | O_DIRECTORY | O_CLOEXEC);
struct open_how how = { .flags = O_RDONLY | O_CLOEXEC,
                        .resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS };
int fd = syscall(SYS_openat2, rootfd, rel, &how, sizeof how);

Opening the returned path with a plain open() follows symlinks and defeats the check.

examples/safe_open.py does both steps in Python (safe_open(path, allow=…, deny=…) returns a file descriptor, or raises). Its test, tests/test_safe_open.py, sets up a workspace with a denied .git, a symlink cfg -> .git and a symlink out -> /etc. The lexical check allows cfg/config and out/passwd, and the confined open refuses both with ELOOP.

Go's os.Root (os.OpenRoot(root), then Root.Open(rel)) and Rust's cap_std::fs::Dir stop escapes from root. They still follow symlinks that stay inside it, so they do not enforce deny roots. With --deny /w/.git, a link /w/cfg -> .git makes cfg/config an allowed path that opens /w/.git/config. Where deny roots matter, refuse symlinks (RESOLVE_NO_SYMLINKS, or O_NOFOLLOW on every component), or make sure nobody can create symlinks inside the roots.

Performance

tests/bench.py measures throughput and memory. One run on an Intel Core Ultra 9 285K (WSL2, Linux 6.6) gave:

Input Time Peak memory
1,000 paths on the command line 0.01 s 19 MiB
100,000 paths on stdin (1.9 MB) 0.34 s 62 MiB
one path of 100,000 segments 0.04 s 63 MiB
one 1 MB name 0.16 s 141 MiB

Memory grows with the input: paths are lists of characters, and stdin is read whole. Expect roughly 30 to 140 times the input size, so split very large batches.

What it does not cover

PathFence decides on the string. Between that decision and the file access, the file system can disagree:

  • Symbolic links. /w/link/secret is "within" /w even if link points to /etc. Agents can plant symlinks themselves, in cloned repositories, extracted archives or through shell tools.

  • Absolute inputs are rewritten, not refused. /w/link/../x becomes /w/x, while the kernel would follow link first and reach a different file.

  • Races. A directory can be replaced by a symlink after the check (TOCTOU). Only resolving at open time, relative to a directory handle, closes this.

  • Hard links, bind mounts, mount points and /proc/self/fd. These and similar aliases are not seen. To stay on one file system, add RESOLVE_NO_XDEV when opening.

  • What the file is. FIFOs and device files inside a root are allowed like any other name.

  • What the caller does. The policy covers paths only. Read, write and create are not distinguished.

  • Case-insensitive or normalising file systems (macOS, Windows). /W and /w, or the NFC and NFD forms of a name, are different strings to PathFence but may be the same file.

  • Where the kernel disagrees. Some inputs fail when the kernel opens them as written, but PathFence normalises them lexically and allows them:

    • file/.., file/ and file/. give ENOTDIR from the kernel when file is not a directory;
    • missing/../d gives ENOENT.

    Their rel (., file and d) opens fine. Nothing escapes the root, but the file opened can differ from the one the original input would have reached, or the input would have failed entirely.

  • Windows paths. No drive letters, UNC paths or \ separators. POSIX only.

  • Bytes that are not UTF-8. The library works on Unicode text and the CLI rejects such input, so POSIX names that are not UTF-8 cannot be allowed.

  • Leading //. POSIX lets an implementation give it a special meaning; PathFence treats it as /.

Use PathFence as the policy layer, to decide which paths an agent may ask for with a precise answer, and use a confined open for the actual access.

Licence

Copyright 2026 the PathFence authors. Apache-2.0; see LICENSE.

The code and proofs were written with Claude Code (Claude Opus 5.5). So far they have been reviewed only by AI, in separate Claude sessions; no human has reviewed them yet.

Metadata

Release files for pathfence 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pathfence 0.1.0
File Size Uploaded Provenance
pathfence-0.1.0.tar.gz 43.6 kB With provenance Details

Built distributions (wheels)

Table of built distributions (wheels) for pathfence 0.1.0
File
pathfence-0.1.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl Python 3 none Linux glibc 2.17+ x86-64 Details
pathfence-0.1.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl Python 3 none Linux glibc 2.17+ ARM64 Details
pathfence-0.1.0-py3-none-macosx_15_0_x86_64.whl Python 3 none macOS 15.0+ x86-64 Details
pathfence-0.1.0-py3-none-macosx_15_0_arm64.whl Python 3 none macOS 15.0+ ARM64 Details

Total release size: 3.9 MB

Release files / pathfence-0.1.0.tar.gz

Download URL pathfence-0.1.0.tar.gz
Size 43.6 kB
Tags Source
SHA-256 checksum
How to use checksums
3cbaf3e5d2aa7669fef83e678f5126ff9560ff3500283b23bf3caa0394715ac5
BLAKE2b-256 checksum
How to use checksums
bff7bf30021b3d1cad093d724af135f81206f06a253de744ada7e190cecc0e64
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / pathfence-0.1.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl

Download URL pathfence-0.1.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
Size 1.2 MB
Tags Linux glibc 2.17+ x86-64 Python 3
SHA-256 checksum
How to use checksums
27aec17877cf5295c6e6db4020effb70040665d8f089c808b8aac50f277bbfd3
BLAKE2b-256 checksum
How to use checksums
6ee2be4dcc46a23e18f07c009cfeb5550ef3bf43c05c3c77cc2514e845633e32
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / pathfence-0.1.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl

Download URL pathfence-0.1.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
Size 1.2 MB
Tags Linux glibc 2.17+ ARM64 Python 3
SHA-256 checksum
How to use checksums
bb4f81c5a189cfd83aae1eabbff46891a7872c868a654faa6a33a21bd5f89f96
BLAKE2b-256 checksum
How to use checksums
9da204428552a4b7be3529bc68eed9ab5d6204584537456490513a736f225e92
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / pathfence-0.1.0-py3-none-macosx_15_0_x86_64.whl

Download URL pathfence-0.1.0-py3-none-macosx_15_0_x86_64.whl
Size 729.2 kB
Tags Python 3 macOS 15.0+ x86-64
SHA-256 checksum
How to use checksums
b6ec1809c76b33b8f56d47a2ee95cb93bf497c8539f7ec87453594e46b59c0cd
BLAKE2b-256 checksum
How to use checksums
bbae5a2e7c5ddd8a526d075dabeb465e93319b575d01d8c0fea50de506a57e03
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / pathfence-0.1.0-py3-none-macosx_15_0_arm64.whl

Download URL pathfence-0.1.0-py3-none-macosx_15_0_arm64.whl
Size 675.3 kB
Tags Python 3 macOS 15.0+ ARM64
SHA-256 checksum
How to use checksums
4e34b9fc9dc9d13ace185d4a8f84e1fbef8f41d364a02c0d8530d8239703a7b4
BLAKE2b-256 checksum
How to use checksums
048913fbb924d1936f9cd67e57d44c5c5e77fcd3c78fb27baf89c17e2709e719
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for pathfence 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 5 files in this release have been uploaded with provenance metadata.

Select from 5 files

Attestation for pathfence-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

5 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page