Contents
Bar audit: tar pilot, 2026-10-24
- Subject: Proven-Commons/tar at commit
7517a86(main on 2026-10-24). - Measured against: the verified bar in
README.mdandFOUNDING-BRIEF.mdof this repo at commitca367b1. - Written by: Claude, an AI agent working for the org owner. This is an adversarial review (factory step 5). It is not the spec approval that bar item 1 asks for. See reviews/README.md.
- Tools: Lean 4.35.0 (commit
eb6cafb), Python 3.13.17, GNU tar 1.35, bubblewrap 0.9.0, Linux x86_64.
Verdict
The pilot does not yet meet the verified bar. Three items are met, one is partly met, and two are not met.
The proofs that exist are sound. Lean’s kernel and the five other kernels shipped with Lean 4.35 accept them, and they use only the standard axioms. The problem is what they state. The headline theorem says less than the public claim that no file in an archive can be written outside the destination folder (F1). On every input tried here, no emitted path left the destination. The proof does not yet show that this always holds.
The project page overstates what the theorem proves, and two statements under “What is not proved” are false (F2, F3). The command-line tool calls the proved gate but not the proved extraction function (F5).
Until the fixes below land, neither repo should call the pilot verified or complete. This repo’s README now calls it built, not yet verified.
Item by item
| # | Bar item | Status | Evidence |
|---|---|---|---|
| 1 | Specification public and approved by a separate reviewer | Not met | The repo has no specification file and no approval record. The head of verification post is not filled yet (brief, “Decisions needed now”, 2). |
| 2 | No sorry or admit |
Met | None in the sources, and #print axioms shows no sorryAx. Caveat (F6): lake build exits 0 when a proof is sorry, so the page’s “The build fails if a proof breaks” does not hold for sorry. |
| 3 | #print axioms shows only the standard axioms |
Met | TarPilot/Verify.lean prints only propext, Classical.choice, and Quot.sound for all four theorems. lake check --paranoid accepts the project under all six kernels. Caveats: the test script’s own axiom check is a denylist (F6), and the theorem it checks is too weak (F1). |
| 4 | Differential tests ran; count, seed, and result in the repo | Met | tests/TEST-RESULTS.md records 16 checks and 0 failures, and the seeds (42, 7, 1) are in tests/run_tests.py. Caveats: the results file does not repeat the seeds, and the inputs fall short of factory step 5 (“random and mutated inputs”). They are 14 fixed archive entries, 3 files for the create round trip, and 3 broken files. One of the broken files is random and one has a flipped byte, but those two are only checked for exit code 1, not compared with tarfile. Every probe in F2 to F4 falls outside these inputs. |
| 5 | Public page lists what is outside the proof | Not met | It names the kernel, compiler, and runtime, not hardware. Two statements under “What is not proved” are false (F2, F3). It does not say that the command-line tool re-implements the proved extraction loop (F5), or that joining the components into one path string is outside the proof (F1). |
| 6 | A stranger can clone, run one command, and re-check | Partly met | The page’s re-check is three commands: lake build, lake env lean TarPilot/Verify.lean, python3 tests/run_tests.py. All pass on a clean clone, but no one of them re-checks the proof alone: lake build accepts a sorry, and the test script accepts a new axiom (F6). Lean 4.35’s lake check --paranoid is one command that does, and it passes (8 minutes here), but the page does not give it. Separately, there is no CI, so nothing re-checks the proof on each change (factory step 7). |
Findings
F1. The headline theorem does not state the headline claim
The claim, on this repo’s README and on the tar page: no file in an archive can be written outside the destination folder, and “a machine-checked proof shows the cleaned path can never climb out of the destination”.
The theorem, extractAll_safe (TarPilot/Archive.lean line 74): every emitted path is the destination followed by a non-empty list of components, none of which is .., empty, or ..
The gap: nothing says a component cannot contain /. The one-component list ["../../etc/passwd"] satisfies the statement. runManifest joins components with / (Main.lean line 50), which gives out/../../etc/passwd. A gate that never splits the name on / proves the exact statement of gate_sound.
Evidence: evidence/WeakStatement.lean. Inside a tar checkout, lake env lean WeakStatement.lean prints:
some ["a/../../etc/passwd"]
'escaping_path_is_published_safe' depends on axioms: [propext]
'badGate_sound' depends on axioms: [propext, Classical.choice, Quot.sound]
Is the code unsafe? No. Its components come from splitOn "/", so none holds a /. That is an unproved fact about String.splitOn. The proof does not carry it.
Fix: evidence/Contained.lean. It adds one check to the cleaner: reject a component that holds / or NUL. On any archive parseArchive reads, that changes no output, and the new gate agrees with the old one on all 17 names in the pilot’s tests. It then proves three statements, with the standard axioms only:
extractAll_contained: every emitted path is the destination followed by a non-empty list of plain names (not empty,.or.., and free of/and NUL).extractAll_joined: the path string the tool builds, by joining with/, splits back into exactly those components.extractAll_resolves_inside: resolving that string lexically, as POSIX resolves a relative path when no component is a symlink, gives the resolved destination followed by the plain names.
The last two assume the destination’s own components hold no /, which holds for the destination the tool builds. Unlike the published statement, these fail for the broken gate. Its output joins and resolves to etc/passwd, which the file shows.
Why this matters past tar: every machine check in the current bar passed. No sorry, standard axioms, tests green. None of them can notice that a theorem states less than its claim. Only a reviewer reading the statement against the claim can, which makes bar item 1 the item everything else rests on.
F2. GNU and pax archives are accepted, not rejected, and some are read wrongly
The page says GNU extensions and pax headers are not supported, and “an archive using them fails to parse instead of being read in a wrong way” (README.md line 39).
What happens: such archives are accepted, with exit code 0. The extension headers are skipped as non-regular entries. When the extension carries nothing the ustar header lacks, as with short ASCII names, the right name and data come out. When it does carry something, the pilot reads a different archive from other tools:
- A long name is cut to its first 100 bytes and loses
.txt. This includes the default output of GNU tar 1.35 (--format=gnu) for any name over 100 bytes. - A pax path override is ignored, so the file is read under its old name.
- A pax non-ASCII name is read as
caf?/notes.txt.
Every path still lands inside the destination, because the gate still runs. Even a pax override that climbs out (../../etc/cron.d/x) is read as out/safe.txt. But the page says such archives are not read at all.
Evidence: evidence/probe_formats.py, run as python3 -I probe_formats.py <tar>/.lake/build/bin/tar-pilot. Three of its blocks, with each long run of one letter cut to nnn… (here 120 n in the name, 98 in the pilot’s output):
## system tar, default format (tar (GNU tar) 1.35)
tarfile reads: [('d/nnn….txt', '0')]
tar-pilot exit: 0
tar-pilot says: SKIP d/ 53
tar-pilot says: SKIP ././@LongLink 76
tar-pilot says: OK out/d/nnn… 1 120
## pax path override that climbs out
tarfile reads: [('../../etc/cron.d/x', '0')]
tar-pilot exit: 0
tar-pilot says: SKIP ././@PaxHeader 120
tar-pilot says: OK out/safe.txt 1 120
## pax non-ASCII name
tarfile reads: [('café/notes.txt', '0')]
tar-pilot exit: 0
tar-pilot says: SKIP ././@PaxHeader 120
tar-pilot says: OK out/caf?/notes.txt 1 120
Fix: reject entries with typeflags L, K (GNU long name and long link), x, g (pax), and any other type outside the supported set, so the page becomes true. Or implement them.
F3. A split name with invalid UTF-8 in one field is misplaced, not rejected
The page says “names that are not valid UTF-8 read as empty and are rejected by the gate” (README.md line 39). That holds for a short name, one that fits the name field with no prefix. It does not hold for a name split over the prefix and name fields.
What happens: bytesToName reads invalid UTF-8 as the empty string (TarPilot/Header.lean line 38), and parseBlock then joins the two fields as if that one were empty (line 57). With an invalid prefix and a valid name, the prefix is dropped and the file lands at out/qqq…/file.txt. With a valid prefix and an invalid name, the file lands at out/ppp…, the name of the prefix directory itself. Only when both fields are invalid is the entry rejected. The same evidence script runs all four cases, here without its tarfile reads lines and with runs of one letter cut:
## split name, invalid UTF-8 in the prefix field only
tar-pilot exit: 0
tar-pilot says: OK out/qqq…/file.txt 1 120
## split name, invalid UTF-8 in the name field only
tar-pilot exit: 0
tar-pilot says: OK out/ppp… 1 120
## split name, invalid UTF-8 in both fields
tar-pilot exit: 0
tar-pilot says: REJECT
## short name (no prefix), invalid UTF-8
tar-pilot exit: 0
tar-pilot says: REJECT
Fix: have bytesToName return Option String and reject the header when either field is not valid UTF-8.
F4. Contiguous files are skipped, unlike other readers
An entry with typeflag 7 (contiguous file) is skipped. The page’s rule covers this: only typeflag 0 or NUL is extracted. But POSIX lets a reader treat 7 as a regular file, and Python’s tarfile does. So the pilot extracts fewer files than other tools, without the page naming this case. Same evidence script:
## contiguous file, typeflag 7
tarfile reads: [('contig.bin', '7')]
tar-pilot exit: 0
tar-pilot says: SKIP contig.bin 55
Fix: extract typeflag 7 as a regular file, or name it on the page as a case that differs from other readers.
F5. The command-line tool does not call the proved extraction function
runManifest (Main.lean lines 37 to 54) calls parseArchive, isRegular, and the proved gate. But it re-implements the loop of the proved extractAll, and joins the path itself. The differential tests drive the command-line tool, so they test that loop and join, not extractAll or extractArchive. Today the two agree when read side by side. Nothing checks it.
Fix: take the OK lines from extractArchive, and work out the REJECT and SKIP lines from the parsed entries, since extractArchive drops those. Or prove that the OK lines runManifest prints are exactly the output of extractAll.
F6. The pilot’s own checks let faults through
Evidence: evidence/plant_faults.py copies a checkout to a temporary directory, plants each fault in TarPilot/Path.lean, and records the exit codes. Its output, with the temporary paths cut:
## fault: sorry
lake build exit: 0
tests/run_tests.py exit: 1 (15/16 checks passed; ...)
TEST-RESULTS.md records: 'TarPilot.gate_sound' depends on axioms: [propext, sorryAx, Classical.choice, Quot.sound]
TEST-RESULTS.md records: - axioms-no-sorry: 'TarPilot.gate_sound' depends on axioms: [propext, sorryAx, Classical.choice, Quot.sound]
## fault: axiom
lake build exit: 0
tests/run_tests.py exit: 0 (16/16 checks passed; ...)
TEST-RESULTS.md records: 'TarPilot.gate_sound' depends on axioms: [propext, Classical.choice, Quot.sound, TarPilot.cheat]
The test script catches a sorry, but lake build alone does not. With axiom cheat : False behind gate_sound, every check passes, and the results file lists the bad axiom without failing. The cause is tests/run_tests.py line 224, which looks only for sorryAx.
Fix: check the axiom list against the allowed set propext, Classical.choice, Quot.sound, or use lake check, which fails on any other axiom.
What holds
- A clean clone builds, and all 16 differential checks pass.
- All four theorems use only
propext,Classical.choice, andQuot.sound. lake check --paranoidpasses. Lean’s kernel,leanchecker-paranoid,lean4lean(58011 declarations),nanoda,con-leche, andcon-ron(58014 declarations each) all accept the project.- No probe in F2 to F4 made the pilot emit a path outside the destination.
Fixes for the tar repo, in order
- F1: adopt the cleaner and theorems from
evidence/Contained.lean, and state on the page the theorem actually proved. - F5: have
runManifesttake itsOKlines fromextractArchive. - F2, F3, F4: reject unsupported typeflags and part-invalid names, add the probes as test cases, and correct the page.
- F6 and item 6: check axioms against the allowed set, and make
lake check --paranoidthe one re-check command on the page. - Item 4: add seeded random and mutated inputs compared with
tarfile(for example, valid headers with fields flipped), and record the seed, input count, and oracle version intests/TEST-RESULTS.md. - Item 5: add hardware, the test oracle, and the filesystem to what a reader must trust.
- Item 1: write the specification, have a reviewer who did not write it approve it, and record the approval.
- Factory step 7: run the check in CI on every push.
- On the page’s status line, drop “built to the verified bar” and “pilot complete” until the bar is met, and point the link to the org’s home repo at
mission(it saysproven-commons, which now redirects).
Re-run this audit
git clone https://github.com/Proven-Commons/tar && cd tar && git checkout 7517a86
lake build
lake env lean TarPilot/Verify.lean
python3 tests/run_tests.py # rewrites tests/TEST-RESULTS.md
cp <mission>/reviews/tar/evidence/*.lean .
lake env lean WeakStatement.lean
lake env lean Contained.lean
python3 -I <mission>/reviews/tar/evidence/probe_formats.py .lake/build/bin/tar-pilot
python3 -I <mission>/reviews/tar/evidence/plant_faults.py .
lake check --paranoid # needs bubblewrap
Limits of this audit
- It checks the six bar items and the probes above. It is not a full review of the header parser, and it ran no fuzzing campaign.
- It ran on Linux x86_64 only.
- An agent wrote it. It can find problems. It cannot approve a specification.