v1.4.2: keep candidate emission inside --emit-candidate (fixes #15) - #16
Merged
Merged
Conversation
…lementing it Issue #15, reproduced on main 2ee3cb2 in a twelve-level sandbox: a scope with n internal '..' segments put HYPOTHESIS.md n-1 levels above the --emit-candidate root, a NUL scope ended in a ValueError traceback with no --json result, and a pre-existing symlink at the candidate directory name was followed out of the root. The frozen rule separates the logical candidate_id from the directory it is stored under, keeps ordinary ids byte-identical on disk, refuses NUL instead of hashing it, and makes the emitter enforce containment itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One oracle per row of docs/V142_COUNTEREXAMPLES.md §8, every case run through the real CLI inside a sandbox whose emit root sits twelve levels deep, every file located by walking and resolving the sandbox rather than by reading the identifier. The suite carries its own mutants: each is applied to a throwaway copy of tools/ and its oracle must go RED there. On main 2ee3cb2: 56 PASS / 36 FAIL — the traversal scopes land outside the root, NUL ends in a traceback, the planted symlink is followed, and the storage helper and emitter guard do not exist yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finding() puts the analysed scope into candidate_id verbatim, and emit_candidates() used that id as the directory name. A scope is an opaque label nobody validated as a path, so `x/../../up` wrote HYPOTHESIS.md outside the --emit-candidate root (issue #15), a NUL in a legacy scope ended in a ValueError traceback, and a symlink already sitting at the directory name was followed out of the root. Identity and storage are now two values: * candidate_storage_component() is the one mapping. An id that is already an ordinary single component is its own directory name, byte for byte, so existing installations keep every path. An id holding `/` or `\`, equal to `.`/`..`/empty, or starting with the reserved prefix is stored as candidate-sha256-<sha256 of the whole id>. NUL or unencodable text returns None and is refused, not hashed. * candidate_dir() is the emitter's own guard, applied whatever the caller handed it: the directory must be a direct child of the resolved root (normpath + dirname, not a string prefix), and a symlink at that name is refused rather than followed. candidate_ids / written / existing / failed keep naming the logical id, HYPOTHESIS.md still states the logical candidate_id and scope_id, no output field is added, output_schema_version stays 2. A refused candidate is listed in candidates_failed with a static reason; with nothing else written the run reports INTERNAL_ERROR / 50. tests/test_candidate_path.py: 97 PASS / 0 FAIL (was 56 / 36 on main), all eight mutants RED, control GREEN. Full repository: 18 suites, 1698 assertions, 0 failures on Python 3.10. Fixes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing outside is looked at A cross-family review of the first cut found two gaps, both reproduced before they were believed: * MEDIUM: the reserved prefix was matched case-sensitively. A verbatim id `CANDIDATE-SHA256-<hex of a/b>` and the hashed name of `a/b` are one directory on a case-insensitive volume (Windows normcase agrees). The prefix is now reserved under casefold(); mutant M_RESERVED_PREFIX_CASE_SENSITIVE turns the helper oracle RED. * LOW: the oracles proved no FILE lands outside the root, not that no path outside is inspected. A spy now records every path handed to os, os.path and open while the emitter runs; mutant M_INSPECT_RAW_PATH_BEFORE_GUARD (an lexists on the raw path before the guard) turns it RED. The same review raised a Windows junction at the storage name as HIGH context (pre-existing). candidate_dir() refuses one via os.path.isjunction where it exists (3.12+). That line is untested here — no Windows runner — and is documented as such in §8.6. tests/test_candidate_path.py: 103 PASS / 0 FAIL, ten mutants RED, control GREEN. Repository: 18 suites, 1704 assertions, 0 failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es; widen the inspection spy
The confirmation round of the cross-family review found the first repair
of each gap incomplete, and both were reproduced before being accepted:
* MEDIUM: casefold() let `candıdate-sha256-<hex>` (U+0131) stay verbatim,
but NTFS compares UPPER-cased names and upper('ı') == 'I', so it is the
hashed directory of `a/b` there. The prefix is now reserved under
upper(), which for every letter of this prefix also covers case folding
(U+017F folds to 's' and upper-cases to 'S'). The helper oracle checks
both folding models with probes for both letters; mutant
M_RESERVED_PREFIX_CASEFOLD_MODEL_ONLY turns it RED.
* LOW: the spy wrapped a fixed list, so os.access or a pathlib write could
inspect outside the root unseen. It now has two nets — wrappers on the
inspecting calls, which raise no audit event, and a sys.addaudithook
hook on every open/create/rename/remove/listing. Two new mutants
(os.access, pathlib write) turn it RED on the spy's own check, not on
an exception; the audit hook records only path arguments, and only
while the emitter runs.
tests/test_candidate_path.py: 107 PASS / 0 FAIL, thirteen mutants RED,
control GREEN. Repository: 18 suites, 1708 assertions, 0 failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gment ID does Round 3 of the cross-family review (LOW): EVIDENCE_LOOP.md said an id whose scope holds a dot segment is hashed. finding() always wraps the scope in <cid>-...-<digest>, so a scope '.' gives trend-bash-.-<digest>, one ordinary component that stays verbatim — exactly what PATH_07 pins. The sentence now names what is hashed: an id holding a separator, or an id that is itself '.', '..' or empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15
What was wrong
finding()builds the candidate id asf"{cid}-{scope}-{digest[:8]}", carrying the analysed scopeverbatim, and
emit_candidates()used that id directly as a directory name:os.path.join(outdir, candidate_id). A scope is an opaque label that nothing validates as a path,so the logical identity of a candidate was also its filesystem path.
Reproduced on
main2ee3cb2 before any change was designed. The sandbox emit root sat twelvedirectories deep, and every file was located by resolving it on disk, never by reading the id:
The repair: identity and storage are separate values
candidate_storage_component(candidate_id)is now the only mapping from a logical id to adirectory name.
byte, so existing installations keep every path (
trend-bash-default-…,…-agent-a-…)./or\, is.,..or empty, or starts with the reservedprefix as a case-insensitive volume compares names (UPPER-casing, as NTFS does). It is then
stored as
candidate-sha256-<sha256 of the complete id>. That name is deterministic, a singlecomponent, never a dot segment, and different ids get different names. Reserving the prefix
means every stored name has exactly one logical source.
than hashed.
candidate_dir(root, candidate_id)is the emitter's own containment check. It applies nomatter what the caller passes in: the candidate directory must be a direct child of the
resolved output root, checked with path semantics (
normpath,dirname) rather than a stringprefix. A symlink, or a Windows junction where Python can detect one, already sitting at that
name is refused, not followed.
candidate_idare never rewritten.candidate_ids,candidates_written,candidates_existingandcandidates_failedstill name logical ids, andHYPOTHESIS.mdstillstates the logical
candidate_idandscope_id. No output field is added andoutput_schema_versionstays 2.candidates_failedwith a static reason ("not a single directoryinside the output root"), which never echoes the input. If nothing else was written, the run
reports
INTERNAL_ERROR/ exit 50 with--jsonoutput, no traceback, no temporary file and nolock left behind.
The production change is about 70 lines in
tools/optimize.py, mostly docstrings: the helper, theguard, and the three lines in
emit_candidates()that use them.Evidence
docs/V142_COUNTEREXAMPLES.md§8, PATH_01–16)and has no deviations.
tests/test_candidate_path.pyruns the matrix:tools/. Every oneturns its oracle RED on the check it is named for, and a control run of the same oracles on the
real source is GREEN:
lexists,access, anda
pathlibopenand to every open/create/rename/remove/listing (a
sys.addaudithookhook). Each path must be theroot, inside it, or one of its ancestors.
tests/test_oneliner_cleanup.sh:11/0. The README count is updated and a CI step is added for the new suite.
valid-DEGRADED recovery, run-id conflict, true retry, zero carry and
--accept-partialallunchanged.
run_idstill raises the sameTypeErrorand leaves the same temporary file. It stays tracked there.Cross-family review before this PR (Codex, three rounds)
CANDIDATE-SHA256-<hex>is thehashed directory of
a/bon a case-insensitive volume.casefold()still letcandıdate-sha256-…(U+0131) through, and NTFS compares UPPER-casednames. The prefix is now reserved under
upper().os.accessandpathlib. It now has two nets.candidate_dir()refuses one throughos.path.isjunctionwhere the interpreter has it(3.12+). That line is untested (no Windows runner), and Windows below 3.12 stays a residual.
Every finding was reproduced before it was accepted. Details:
docs/V142_COUNTEREXAMPLES.md§8.6.Residual limits, stated rather than hidden
os.makedirsare two system calls. Aprocess that can already write inside OUTDIR could swap a symlink in between them. Closing that
race needs
dir_fd/O_NOFOLLOWcreation throughout, which is a different design.HYPOTHESIS.mdsymlink. A symlink planted atHYPOTHESIS.mdinside a real candidatedirectory is not candidate-controlled identity, and this PR does not address it.
finding()'s owndigest, before any path exists. That is identity hashing, not path handling, and deserves its
own issue.
\was a single component on POSIX; it nowmoves to the hashed name so the storage rule is the same on every platform.
as before (
ENAMETOOLONG, controlledINTERNAL_ERROR).upper()/casefold()model NTFS and macOS namecomparison; neither has been run on a real case-insensitive volume in CI. They can only
over-match, which hashes more ids.
Not in this PR
Version stays 1.4.1. There is no packaging, tag or release, no change to
skills/,hooks/orthe plugin manifests, and no work on issue #14 or PR #7.
🤖 Generated with Claude Code