Skip to content

v1.4.2: keep candidate emission inside --emit-candidate (fixes #15) - #16

Merged
ipeterpetrus merged 6 commits into
mainfrom
fix/v1.4.2-candidate-path-containment
Sep 22, 2026
Merged

ipeterpetrus merged 6 commits into
mainfrom
fix/v1.4.2-candidate-path-containment

Conversation

@ipeterpetrus

Copy link
Copy Markdown
Owner

Fixes #15

What was wrong

finding() builds the candidate id as f"{cid}-{scope}-{digest[:8]}", carrying the analysed scope
verbatim, 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 main 2ee3cb2 before any change was designed. The sandbox emit root sat twelve
directories deep, and every file was located by resolving it on disk, never by reading the id:

--scope-id x/../../up              CANDIDATE 10   HYPOTHESIS.md 1 level above the emit root
--scope-id x/../../../../up4       CANDIDATE 10   3 levels above
--scope-id a/b/c/ + 12 x ../       CANDIDATE 10   9 levels above
history scope x/../../../hist      CANDIDATE 10   2 levels above
history scope a<NUL>b              rc 1, ValueError traceback from os.makedirs, no --json output
emit/<candidate dir> is a symlink  CANDIDATE 10   written through the link, outside the root

The repair: identity and storage are separate values

  • candidate_storage_component(candidate_id) is now the only mapping from a logical id to a
    directory name.
    • An id that is already a single ordinary path component is used as its own name, byte for
      byte, so existing installations keep every path (trend-bash-default-…, …-agent-a-…).
    • An id is hashed if it contains / or \, is ., .. or empty, or starts with the reserved
      prefix 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 single
      component, never a dot segment, and different ids get different names. Reserving the prefix
      means every stored name has exactly one logical source.
    • An id containing NUL, or text the filesystem encoding cannot represent, is refused rather
      than hashed.
  • candidate_dir(root, candidate_id) is the emitter's own containment check. It applies no
    matter 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 string
    prefix. A symlink, or a Windows junction where Python can detect one, already sitting at that
    name is refused, not followed.
  • The scope and the candidate_id are never rewritten. candidate_ids, candidates_written,
    candidates_existing and candidates_failed still name logical ids, and HYPOTHESIS.md still
    states the logical candidate_id and scope_id. No output field is added and
    output_schema_version stays 2.
  • A refused candidate appears in candidates_failed with a static reason ("not a single directory
    inside the output root"), which never echoes the input. If nothing else was written, the run
    reports INTERNAL_ERROR / exit 50 with --json output, no traceback, no temporary file and no
    lock left behind.

The production change is about 70 lines in tools/optimize.py, mostly docstrings: the helper, the
guard, and the three lines in emit_candidates() that use them.

Evidence

  • The frozen matrix is committed before the code (docs/V142_COUNTEREXAMPLES.md §8, PATH_01–16)
    and has no deviations.
  • tests/test_candidate_path.py runs the matrix:
    • main 2ee3cb2: 56 PASS / 36 FAIL;
    • this branch: 107 PASS / 0 FAIL on Python 3.10, 3.11 and 3.13 locally.
  • The suite also carries thirteen mutants, each applied to a throwaway copy of tools/. Every one
    turns its oracle RED on the check it is named for, and a control run of the same oracles on the
    real source is GREEN:
    • raw id used as path
    • separators unmapped
    • NUL unchecked
    • containment check removed
    • unsafe ids collapsed to one name
    • safe ids hashed
    • digest salted per run
    • symlink check removed
    • reserved prefix matched case-sensitively
    • reserved prefix matched by casefold only
    • three ways of inspecting a path outside the root before the guard: lexists, access, and
      a pathlib open
  • The inspection oracle records every path the emitter hands to the inspecting calls (wrapped)
    and to every open/create/rename/remove/listing (a sys.addaudithook hook). Each path must be the
    root, inside it, or one of its ancestors.
  • Full repository: 18 suites, 1708 assertions, 0 failures. tests/test_oneliner_cleanup.sh:
    11/0. The README count is updated and a CI step is added for the new suite.
  • PR v1.4.2: fail closed on optimizer evidence quality #13 gates re-run on this branch: R142_01–06, B1 recovery, invalid-UTF-8 trust boundary,
    valid-DEGRADED recovery, run-id conflict, true retry, zero carry and --accept-partial all
    unchanged.
  • Issue Optimizer crashes and can leave a temp candidate when legacy run_id is non-string #14 behaviour is byte-for-byte unchanged: a non-string run_id still raises the same
    TypeError and leaves the same temporary file. It stays tracked there.

Cross-family review before this PR (Codex, three rounds)

  • Round 1. NEEDS-FIX with two findings:
    • MEDIUM: the reserved prefix was case-sensitive, so a verbatim CANDIDATE-SHA256-<hex> is the
      hashed directory of a/b on a case-insensitive volume.
    • LOW: the oracles did not prove that nothing outside the root is inspected.
  • Round 2. Both repairs PARTIAL:
    • casefold() still let candıdate-sha256-… (U+0131) through, and NTFS compares UPPER-cased
      names. The prefix is now reserved under upper().
    • The spy missed os.access and pathlib. It now has two nets.
  • Round 3. Both CLOSED. No new CRITICAL, HIGH or MEDIUM finding; one LOW doc wording, fixed.
  • The same review raised a pre-existing Windows directory junction as HIGH context.
    candidate_dir() refuses one through os.path.isjunction where 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

  • Check-then-create race. The containment check and os.makedirs are two system calls. A
    process that can already write inside OUTDIR could swap a symlink in between them. Closing that
    race needs dir_fd/O_NOFOLLOW creation throughout, which is a different design.
  • Planted HYPOTHESIS.md symlink. A symlink planted at HYPOTHESIS.md inside a real candidate
    directory is not candidate-controlled identity, and this PR does not address it.
  • Lone UTF-16 surrogate. A scope holding one still crashes earlier, inside finding()'s own
    digest, before any path exists. That is identity hashing, not path handling, and deserves its
    own issue.
  • Backslash ids are re-homed. An id containing \ was a single component on POSIX; it now
    moves to the hashed name so the storage rule is the same on every platform.
  • Over-long names. An ordinary id longer than the filesystem's name limit still fails exactly
    as before (ENAMETOOLONG, controlled INTERNAL_ERROR).
  • Folding is modelled, not measured. Python's upper()/casefold() model NTFS and macOS name
    comparison; neither has been run on a real case-insensitive volume in CI. They can only
    over-match, which hashes more ids.
  • Windows junctions below Python 3.12 (see above).

Not in this PR

Version stays 1.4.1. There is no packaging, tag or release, no change to skills/, hooks/ or
the plugin manifests, and no work on issue #14 or PR #7.

🤖 Generated with Claude Code

ipeterpetrus and others added 6 commits September 22, 2026 18:29
…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>
@ipeterpetrus
ipeterpetrus merged commit b966dc1 into main Sep 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Candidate emission can escape --emit-candidate through an unsafe scope path

1 participant