Skip to content

chore(deps): bump the npm_and_yarn group across 3 directories with 2 updates - #155

Merged
github-actions[bot] merged 1 commit into
mainfrom
dependabot/npm_and_yarn/npm_and_yarn-be68fdcab3
Aug 8, 2026
Merged

chore(deps): bump the npm_and_yarn group across 3 directories with 2 updates#155
github-actions[bot] merged 1 commit into
mainfrom
dependabot/npm_and_yarn/npm_and_yarn-be68fdcab3

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 8, 2026

Copy link
Copy Markdown

Bumps the npm_and_yarn group with 2 updates in the / directory: dompurify and js-yaml.
Bumps the npm_and_yarn group with 1 update in the /apps/desktop directory: dompurify.
Bumps the npm_and_yarn group with 1 update in the /website directory: dompurify.

Updates dompurify from 3.4.12 to 3.4.13

Release notes

Sourced from dompurify's releases.

DOMPurify 3.4.13

  • Fixed an issue with hook removal during IN_PLACE sanitization, thanks @​koyokr
  • Fixed an issue with hooks potentially bypassing the clone guard, thanks @​AkshayjainG
  • Fixed an issue with DOM clobbering via ownerDocument during IN_PLACE, thanks @​AkshayjainG
  • Bumped several dependencies where possible
Commits

Updates js-yaml from 4.3.0 to 4.3.1

Changelog

Sourced from js-yaml's changelog.

4.3.1 - 2026-07-31

Security

  • [backport] Remove quadratic complexity from !!omap duplicate key detection.
Commits

Updates dompurify from 3.4.12 to 3.4.13

Release notes

Sourced from dompurify's releases.

DOMPurify 3.4.13

  • Fixed an issue with hook removal during IN_PLACE sanitization, thanks @​koyokr
  • Fixed an issue with hooks potentially bypassing the clone guard, thanks @​AkshayjainG
  • Fixed an issue with DOM clobbering via ownerDocument during IN_PLACE, thanks @​AkshayjainG
  • Bumped several dependencies where possible
Commits

Updates dompurify from 3.4.12 to 3.4.13

Release notes

Sourced from dompurify's releases.

DOMPurify 3.4.13

  • Fixed an issue with hook removal during IN_PLACE sanitization, thanks @​koyokr
  • Fixed an issue with hooks potentially bypassing the clone guard, thanks @​AkshayjainG
  • Fixed an issue with DOM clobbering via ownerDocument during IN_PLACE, thanks @​AkshayjainG
  • Bumped several dependencies where possible
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore <dependency name> major version will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
  • @dependabot ignore <dependency name> minor version will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
  • @dependabot ignore <dependency name> will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
  • @dependabot unignore <dependency name> will remove all of the ignore conditions of the specified dependency
  • @dependabot unignore <dependency name> <ignore condition> will remove the ignore condition of the specified dependency and ignore conditions
    You can disable automated security fix PRs for this repo from the Security Alerts page.

…updates

Bumps the npm_and_yarn group with 2 updates in the / directory: [dompurify](https://github.com/cure53/DOMPurify) and [js-yaml](https://github.com/nodeca/js-yaml).
Bumps the npm_and_yarn group with 1 update in the /apps/desktop directory: [dompurify](https://github.com/cure53/DOMPurify).
Bumps the npm_and_yarn group with 1 update in the /website directory: [dompurify](https://github.com/cure53/DOMPurify).


Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.12...3.4.13)

Updates `js-yaml` from 4.3.0 to 4.3.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](nodeca/js-yaml@4.3.0...4.3.1)

Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.12...3.4.13)

Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.12...3.4.13)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Aug 8, 2026
@dependabot
dependabot Bot requested a review from dizhaky as a code owner August 8, 2026 07:50
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Aug 8, 2026
@github-actions
github-actions Bot merged commit 7e38fa5 into main Aug 8, 2026
10 checks passed
@github-actions
github-actions Bot deleted the dependabot/npm_and_yarn/npm_and_yarn-be68fdcab3 branch August 8, 2026 07:50
dizhaky pushed a commit that referenced this pull request Aug 9, 2026
Records the truncation defect as the failure half of a comment I wrote two
rounds earlier, the fixture that reproduces it, and the thirteen-fixture
control showing exactly one line moved. Also itemizes every red check on the
branch with its cause, including the electron pin mismatch that #155
introduced on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8
dizhaky added a commit that referenced this pull request Aug 9, 2026
…cret-scan (#156)

* fix(security): guard psutil sdist extraction against tarslip

A local CodeQL run over main surfaced two findings in hermes_cli/main.py
that are not taint-model noise. Both are fixed here.

py/tarslip — _install_psutil_android_compat called tar.extractall() on a
psutil sdist fetched over the network with no member validation and no
extraction filter. The URL is pinned but the download's checksum is never
verified, so a compromised or MITM'd sdist could write outside the temp
dir via ../ or absolute members. Adds the guard agent/curator_backup.py
already uses: reject traversal members, then filter="data" with a
TypeError fallback for interpreters older than 3.11.4.

scripts/install_psutil_android.py had the identical unguarded call. Its
docstring says the two copies are kept in sync and removed together, so
both are fixed. CodeQL flagged only the main.py one — an equivalent bug
in a standalone script went unreported.

py/incomplete-url-substring-sanitization — _infer_stepfun_region matched
"api.stepfun.com" by substring, so api.stepfun.com.example.net inferred
china. Now parses the host and compares exactly. Impact is cosmetic: the
inferred region only pre-selects a menu entry, and the endpoint actually
used comes from the user's explicit choice. Fixed because it is two lines
and strictly more correct.

Left alone: agent/curator_backup.py:619 and hermes_cli/main.py:7001 both
matched a grep for unguarded extractall, but only via their fallback
line — both are already correctly defended.

Verification: 15 new tests. Positive control — reverting main.py while
keeping the tests fails 4 of them; restoring the guard returns 15/15. One
test initially passed for the wrong reason and was rewritten: tarfile's
gettarinfo(arcname=...) strips a leading slash, so the absolute-path case
was never absolute. Full tests/hermes_cli: clean main 5 failed / 5235
passed, with this change 5 failed / 5250 passed — same pre-existing
ordering-dependent flakes, +15 passing. ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* fix(security): validate link targets, not just member names

Addresses all three findings from the Codex review on #156. Two were real
defects in my own fix; the third was a process violation.

P1 — the name-only guard was not equivalent to filter="data". On Python
3.11.0-3.11.3, in range for requires-python >=3.11, the unfiltered
fallback runs and a member with a clean NAME can be a symlink whose
TARGET escapes:

    psutil-7.2.2/link      -> ../../outside   (name passes validation)
    psutil-7.2.2/link/file                    (written outside the dest)

Reproduced with a working archive before fixing. The guard now resolves
link targets against the link's own directory and requires containment,
and refuses non-regular members — both of which the data filter does.
Extracted to agent.file_safety.assert_safe_tar_members so there is one
implementation instead of three drifting copies.

This also corrects a claim in the previous commit. It said
agent/curator_backup.py was "already correctly defended" and needed no
change. Wrong: it had the identical name-only + unfiltered-fallback
shape and the same symlink gap — and it extracts into the skills
directory (executable content) rather than a temp dir, so the gap
mattered more there than in the site CodeQL flagged. Now uses the
shared helper.

P2 — urlparse(...).hostname raises ValueError on a malformed netloc
(https://[foo). _infer_stepfun_region takes unvalidated STEPFUN_BASE_URL
/ model.base_url values, so the previous commit turned a total function
partial and would abort provider setup before the region picker — a
regression against the substring version it replaced. Now catches and
defaults to international.

P1 — verification bypassed the required runner. AGENTS.md says ALWAYS
use scripts/run_tests.sh, never bare pytest, because the wrapper
enforces hermetic CI parity. The previous commit reported "5 failed /
5250 passed, pre-existing ordering flakes" from a direct pytest run.
Under the wrapper: 241 files, 5261 tests passed, 0 failed. Those five
were an artifact of the bypass, not a property of the tree.

Tests: 21 (up from 15), adding a symlink-escape archive, an absolute
link target, a benign internal relative link, and three malformed-netloc
URLs. Positive control — reverting only the three source files while
keeping the tests fails 5 of them; restoring returns 21/21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record the merge, the hardlink defect, and the test relocation

* Check tar member paths under Windows semantics too; fix secret-scan scope

**Windows path semantics (Codex P2).** The guard parsed member names with
PurePosixPath, but extractall builds its destination with os.path, which on
Windows also splits on backslash and honours drive letters. A member named
`..\outside` or `C:\outside` has no ".." part and no leading "/" under POSIX
parsing, so it passed the guard while escaping at extraction. Names are now
rejected if either reading says they escape, and link targets are resolved
with backslash treated as a separator. This also refuses a filename that
genuinely contains a backslash — legal on POSIX but vanishingly rare, and the
conservative direction for a security guard.

7 new cases; positive control confirms reverting to POSIX-only parsing fails
exactly those 7 and nothing else.

**secret-scan.yml scope.** Merging main into this branch made the workflow's
own gitleaks job fail: a merge commit's diff carries everything the merge
brought in, so the BEFORE..AFTER push range re-scanned 60 MB of main's history
and reported 74 findings, none introduced by the branch. Reproduced locally
with the same pinned gitleaks 8.24.3 and fixed by excluding commits already
reachable from the default branch. Verified both directions: the narrowed
range reports no leaks here, and still catches a synthetic key planted in an
ordinary commit (canary tested on a throwaway branch, never pushed).

Left as-is: any branch merging main hits this, so the check was failing for a
reason unrelated to whatever change was under review — the exact way a scanner
trains people to ignore it.

* secret-scan: also scan changed files' content, closing the merge blind spot

gitleaks' log-based scanning cannot see content that exists only in a merge
commit — it walks per-commit diffs and never attributes a merge's conflict
resolution to anything. Verified against the pinned 8.24.3: a key introduced
solely in a resolution is missed even by a full-history scan (3 commits
scanned, no leaks) and shows up only under --no-git.

So a secret committed while resolving a conflict passed this check silently.
Now the changed files' final content is scanned as well, which is where such a
resolution lands. Scoping to changed files preserves the diff-scoped noise
properties: untouched files carrying pre-existing findings are never
re-reported.

Verified both directions in a throwaway repo: log scan misses the
merge-resolution key, the added scan catches it; and on this branch the added
scan over its 7 changed files reports no leaks.

* secret-scan: scope by changed paths, and fail instead of passing on an empty scan

Three further defects found by running the workflow step verbatim against this
repo rather than reasoning about it.

**A silently empty scan passed.** The first version of the path-scoped range
left a trailing space, producing an empty pathspec; git rejected it, and
gitleaks reported a "partial scan" of ~0 bytes and exited 0. The check went
green having scanned nothing. All calls now go through a wrapper that treats
"failed to scan"/"partial scan" as a failure.

**Stale branch history swamped the range scan.** A branch whose earlier PRs
were squash-merged keeps its original commits forever, since squashing puts
new commits on the default branch and the branch's own history never becomes
an ancestor. Here that was 78 commits and 81 findings, every one of them a
repo test fixture the change never touched. The walk is now restricted to the
paths the change affects. Trade-off recorded in the file: a secret added and
then fully deleted within the branch is no longer walked.

**The content pass used the wrong base.** It diffed against the push's
before-SHA, which on a push that merges the default branch in predates that
merge — naming 7375 files and re-reporting 126 findings from files the branch
never edited. It now measures against the default branch.

Verified by extracting the step from the YAML and running it for both event
types: PR and push each scan 12 commits / 7 changed files and report clean,
and a synthetic key planted in a changed file makes the step exit 1.

* Fix two pre-existing main failures blocking this PR's required checks

Neither is caused by this change; both reproduce byte-identically on
origin/main, in files this PR does not otherwise touch. They are swept in
because they are one-liners and they gate "All required checks pass".

- tests/acp/test_session.py:291 — an unclosed list literal, a hard syntax
  error. It fails `ruff check .` on main, and pytest could not collect the
  file at all, so its 15 tests have been silently dead rather than passing.
  Closing the bracket restores them: 15 passed.

- plugins/memory/memgw/__init__.py:216,220 — bare read_text()/write_text()
  without encoding=, the repo's documented Windows footgun (locale-dependent
  decode on cp936/cp1252). Both now pass encoding="utf-8".

After these, `ruff check .` and `scripts/check-windows-footguns.py --all` are
both clean on this branch.

* Stop trying to validate tar members; don't call unfiltered extractall at all

Codex found a third bypass of the lexical guard, and it is the one that
settles the approach. Given `a -> .`, a directory `a/b`, and
`a/b/link -> ../../outside`, the link's lexical depth is 2 but its real depth
is 1, so the target normalizes as contained while resolving outside.
Reproduced before changing anything:

    GUARD: ACCEPTED
    extractall OK
    file written OUTSIDE dest: True
       found at: ./outside/pwned

That is not a fourth thing to patch. Containment depends on what earlier
members created, and no check over member metadata can know that. This guard
had already been corrected twice — escaping symlink targets, then hardlink
resolution — and was still wrong.

So `assert_safe_tar_members` is replaced by `safe_extract_tar`, which uses the
stdlib `data` filter where it exists (3.11.4+) and otherwise writes each member
itself, creating only directories and regular files. Nothing that can redirect
a later path is ever created, which makes the traversal question moot rather
than answering it correctly. This is the same conclusion main reached
independently for the psutil sdist in hermes_cli/psutil_android.

Deliberately stricter than `data` in one respect, asserted by a test so it
can't drift: `data` permits a symlink whose target stays inside the tree; the
fallback refuses every link member. Supporting them safely means resolving
through links already created — the complexity that produced all three
bypasses. Affects 3.11.0-3.11.3 only.

Tests rewritten to assert the property rather than the mechanism: extract for
real, then check the filesystem. Both paths are exercised everywhere by forcing
the fallback, since the real one is unreachable on a modern interpreter. Each
of the three historical bypasses is pinned as a scenario.

Positive control: reinstating the unfiltered fallback fails 9 tests, including
the chained-symlink case, all on the manual-fallback variant. 43 pass with
curator_backup's own suite.

* secret-scan: scan stored blobs, and baseline the content pass against the base

Two more real defects in the content pass, both from Codex review.

**It scanned the wrong bytes for symlinks.** The loop copied from the working
tree, so `[ -f ]` skipped a tracked path whose symlink target dangles, and `cp`
dereferenced a live one — scanning the *target's* content instead of the link
blob git actually stores. Verified both. It now materializes `git show
<rev>:<path>`, which gives the stored bytes for every kind of tracked path.

**It re-reported pre-existing findings.** Scanning a touched file's full
content meant that editing a file which already contains a fixture credential
failed the PR on someone else's finding — the same class of noise the
diff-scoped design exists to avoid, just narrowed to fewer files. The pass now
builds a gitleaks baseline from the *base* revision of the same files and
passes `--baseline-path`, so only findings the change actually introduces fail.

Two details of that are load-bearing and were found by testing, not reading:
both scans must run from inside their directory with `--source .`, because a
fingerprint embeds the file path and two temp dirs never match by absolute
path; and both must use identical flags, because `--redact` rewrites the
recorded Secret and a baseline built without it matches nothing. With either
detail wrong the baseline silently suppresses nothing.

Verified end to end by running the extracted step over purpose-built repos:

  A  new credential (as a symlink target)      -> exit 1
  B  pre-existing finding in a touched file    -> exit 0
  C  new credential added to that same file    -> exit 1
  D  credential only in a merge resolution     -> exit 1, caught by the content
                                                  pass after the log scan
                                                  reports clean

* docs(system-log): record the abandoned guard approach and the content-pass fixes

* Refuse pre-existing symlinks in the destination; restore hardlinked snapshots

Two more from Codex review, both reproduced first.

**The destination can carry the redirect.** Validating archive members is not
enough: curator_backup preserves skills/.hub across a rollback, so an existing
skills/.hub/link -> /outside makes an ordinary member .hub/link/victim.txt land
outside the tree — with no link member in the archive at all. Verified: the
extraction completed and the outside file read PWNED. Each path component is
now opened with O_NOFOLLOW relative to its parent directory fd, so an existing
symlink anywhere along the path raises instead of being followed. Windows has
neither O_NOFOLLOW nor dir_fd support, so it keeps plain path operations —
unchanged from before rather than a new gap, and noted as such in the code.

**Legitimate snapshots contain hardlinks.** tarfile.add() stores the second
occurrence of an inode as a LNKTYPE member, so a skills tree with hardlinks
produces a snapshot the previous commit refused — making snapshot_skills()
output unrestorable on exactly the interpreters this path serves. Verified
against a real snapshot. Safe hardlinks are now materialized as copies rather
than links: same content, and a copy cannot be used to reach anything else.
The linkname is validated the same way member names are, so the escaping
hardlink case from the earlier round is still refused.

Positive controls, each reverting one thing: dropping the O_NOFOLLOW walk fails
exactly the destination-symlink test; refusing link members again fails exactly
the two hardlink tests. 38 pass.

Also in this commit, for the secret-scan workflow:

- **NUL-delimited path reads.** git quotes and backslash-escapes paths with
  non-ASCII bytes, tabs or newlines, so `git show "HEAD:\"r\303\251sum\303\251.txt\""`
  failed and the loop's `|| continue` skipped the blob silently. Reproduced.
  Now uses -z everywhere.
- **Renames.** A pure rename has no blob at the base under its new path, so the
  baseline came out empty and every pre-existing finding in the moved file read
  as new. The base side is now materialized from the *source* path, and both
  sides of a rename go into the log pathspec — limiting the walk to the
  destination hides the source, so git renders the rename as a wholesale
  addition. Both halves were needed: fixing only the baseline still failed,
  because the log pass fired first.

* docs: correct the link-handling docstring and record rounds eight and nine

The docstring still claimed the fallback "refuses every link member", which
stopped being true when hardlinks became copies. It now states the actual
asymmetry and why the two kinds differ: a symlink can redirect a later path, a
copy cannot.

* docs(system-log): diagnose the JS/TS failures as a react/react-dom skew on main

Replaces a causal argument ("this branch changes no JS") with the actual
cause: root hoisted node_modules/react resolves to 19.2.8 while react-dom is
19.2.7, though every workspace declares 19.2.7. package-lock.json is
byte-identical on origin/main, so this predates the branch and blocks every JS
check repo-wide.

Not fixed here: realigning needs a regenerated lockfile, and which side to
move is a dependency-policy call rather than a mechanical one.

* secret-scan: key the content baseline on the secret's value, not its location

Codex found that --baseline-path can suppress a genuinely new secret. The
stated mechanism was incomplete — a baseline entry is distinguished by
Entropy, not only the fingerprint — but the conclusion holds, and worse than
described: Entropy is a rounded float over the secret's characters, so any
permutation of the baselined value collides exactly.

    baseline entropy: 5.416892
    head     entropy: 5.416892
    -> no leaks found

A different, real credential at the same path/rule/line was suppressed. That
is precisely the merge-resolution case the content pass exists to catch
("replace the placeholder with the real key"), so location-keyed exemption was
the wrong key entirely.

Replaced with scripts/ci/diff_gitleaks_findings.py, which compares reports on
(RuleID, Secret). File and line are deliberately not part of the key: a secret
that merely moved was not introduced, and one whose value changed was,
wherever it sits — keying on location reports every line shift and misses
every in-place replacement. Reports are unredacted so values can be compared
and are never echoed; only path/line/rule of a new finding is printed, and a
test asserts the secret never reaches stdout or stderr.

Also reverted, because the premise was wrong: a previous commit here started
restoring archived directory modes, on the stated grounds that the filtered
path preserves them. It does not — verified, filter="data" turns a 0700
directory into 0755. Preserving them only in the fallback would make a
restored snapshot's permissions depend on the interpreter, which is the exact
divergence this function exists to remove. The test now asserts parity between
the two paths rather than a mode I assumed.

Verified: the entropy-colliding replacement is now detected; unchanged files,
moves and removals pass; malformed reports fail closed. Workflow matrix
re-run end to end — this branch passes, and the new-credential, unicode-path
and merge-resolution repos all exit 1.

* docs(system-log): record the entropy-collision fix and the reverted dir-mode change

* Refuse extraction where no-follow cannot be enforced, instead of documenting it

Codex: the no-dir_fd branch fell back to plain path operations, so on Windows
with Python 3.11.0-3.11.3 the destination-symlink fix did not apply at all —
a preserved .hub/link junction would be followed exactly as before.

That is a fair hit. I had written the fallback off as "unchanged from before
rather than a new gap" and said so in the docstring. But preserving prior
behaviour is not the standard for a function whose entire purpose is to
guarantee the property, and a branch that silently performs no enforcement is
worse than one that admits it cannot: callers cannot tell the difference.

It now raises, naming the fix. The refused combination is narrow — an
interpreter predating filter="data" (3.11.0-3.11.3) *and* a platform without
dir_fd (Windows). On 3.11.4+ the filter runs and the walk is never reached.
Failing to restore a backup is a poor outcome; restoring it through a junction
that redirects outside the skills tree is a worse one, and 3.11.4 fixes it.

Also removes the now-unreachable `parent is None` branches in _write_file and
_copy_within, and tightens _walk_dirs to return int rather than int | None.
Dead fallback paths are how this hole got in.

Two tests: extraction is refused when _HAVE_DIR_FD is false and nothing is
written, and the filtered path still succeeds under the same condition, since
only the fallback is affected.

* docs(system-log): record round eleven — the rationalised-away no-dir_fd fallback

* Sanitize file modes like the data filter; count secret occurrences as a multiset

Two more from Codex, both verified against the stdlib rather than assumed —
the last round taught me not to guess at what filter="data" does.

**Regular-file modes were applied verbatim.** `data` sanitizes them: drop
group/other write, clear all execute bits unless the owner had execute, then
guarantee owner read/write. The manual path used `member.mode & 0o777`, so a
0777 member restored world-writable on 3.11.0-3.11.3 and 0755 everywhere else
— executable skill content editable by any local user, on those interpreters
only. Measured:

    archive   data filter   manual (before)
       0777          0755              0777
       0666          0644              0666
       0400          0600              0400
       0444          0644              0444

Now shares one helper, checked exhaustively: all 512 modes in 0o000-0o777
agree with the stdlib, and none leaves group/other write or drops owner rw.
This is the opposite case to the directory-mode report I rejected last round —
`data` ignores directory modes and does sanitize regular files, so matching it
means restoring neither and sanitizing the latter.

**Set membership discarded multiplicity.** A resolution adding a *second* copy
of a credential already present in the changed files left the (rule, secret)
key unchanged, so both copies were exempt and nothing was reported. Switching
to value-based keys last round fixed replacement detection and introduced this.
Occurrences are now counted: only as many as existed at the base are exempt,
and the surplus is reported.

Positive controls: reverting the mode helper fails exactly the 4 file-mode
tests; reverting to set membership fails exactly the 2 multiplicity tests.
80 tests pass across the three affected files.

* docs(system-log): record round twelve — mode sanitization and the multiset regression

* secret-scan: keep the PR base, fail closed on partial scans, clean up reports

Three findings, one of which was leaking.

**Unredacted reports outlived the step.** BASE_JSON/HEAD_JSON were written as
*siblings* of the temp dirs (`${BASE_DIR}.findings.json`), so the trap that
removed the directories never touched them — while the comment beside them
claimed they were trap-cleaned. They contain the exact credentials the scan
detects. Verified on the sandbox: the old code left 603-byte reports with real
findings behind; the fix leaves nothing. Everything now lives under one
temp root that the trap removes.

**A PR's base was overridden.** For a pull request targeting a non-default
branch, EXCLUDE is normally set, and the content pass replaced the PR's own
base with merge-base(default, HEAD) — scanning and baselining the wrong diff.
Target-branch fixtures would be re-reported, and a credential present only on
the default branch would exempt the same value newly introduced into the
target. The override is now push-only.

**Partial scans were not caught in the content pass.** The bespoke block
checked only "failed to scan", and only on HEAD. That is the same fail-open I
had already fixed once with the `scan()` wrapper and then reintroduced by
hand-rolling a second call site. Both sides now go through one helper checking
"failed to scan|partial scan".

Matrix re-run: this branch passes; new-credential, unicode-path and
merge-resolution repos each exit 1.

* docs(system-log): record round thirteen, including the disproved ordering claim

* secret-scan: literal pathspecs, newline-safe parents, fail on any nonzero status

Four more, all cheap and all real.

**$(dirname) re-corrupted what -z preserved.** Command substitution strips
trailing newlines, so a directory component ending in one ("dir\n/file") was
created as "dir", the redirect failed, and the loop's own `continue` dropped
the blob. Proven both ways on a repo with such a path:

    old: materialized 1 of 2, credential present in 0 files
    new: materialized 2 of 2, credential present

Uses ${dst%/*} now — no subshell, nothing to strip. Same class as the quoting
bug fixed two rounds ago: -z got the record into the loop intact and the next
line mangled it again.

**Generated pathspecs were not marked literal.** A tracked file named
":(literal)foo" is read as a *request* for "foo", so the file itself is never
walked. Every generated name is now prefixed with :(literal).

**|| true swallowed every failure except two known log strings.** With
--exit-code 0, findings are not an error, so any nonzero status is a genuine
execution failure — killed, panicked, bad arguments. Matching "failed to scan|
partial scan" covered only the failures I had happened to observe; the status
is now captured and any nonzero one fails the step.

**Hardlink copies lost their mtime.** Regular files preserve the archived
timestamp and the copy path did not, so in a real snapshot the second of two
hardlinked files restored stamped "now" while its twin kept 2000-01-01. Test
asserts both siblings.

* secret-scan: drop a comment block duplicated by an earlier edit

* docs(system-log): record round fourteen and the final suite comparison

Four Codex findings, all real, all fixed in 14cfd1c: the `|| true` that
swallowed every failure the two grep patterns did not name; generated
pathspecs read as pathspec magic; `$(dirname)` stripping trailing
newlines back out of the NUL-safe paths; and hardlink copies losing
their mtime.

Also records the final full-suite comparison against the same-machine
origin/main baseline: 47 failing files on the branch against 48 on main,
zero of them branch-unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Preserve literal backslashes in POSIX member names

`_safe_member_parts` rewrote every backslash to a forward slash before
splitting, and the docstring described that as "refusing" a backslash
filename. It was not a refusal: a backslash is a legal POSIX filename
character, so `demo/a\b` was silently relocated to `demo/a/b`. Given a
snapshot holding both, one entry overwrote the other and a rollback lost
a file without reporting anything.

Validation still runs under POSIX and Windows rules, so Windows-style
escapes stay refused everywhere. Only the split is now host-specific.

`filter="data"` keeps the two names distinct on POSIX, so the new tests
fail on the manual fallback alone and pass on the stdlib variant — the
divergence, not my reading of it, is what they pin.

Also states the Windows/3.11.0-3.11.3 gap plainly: on that combination
every curator rollback fails before its first entry while
`requires-python` still advertises support. The fix is to floor at
3.11.4; left as a follow-up because this environment's uv cannot parse
the repo's uv.lock schema, so the lockfile cannot be regenerated and
`uv lock --check` would fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round fifteen

Two findings. The backslash one caught a claim I had published twice —
that a backslash filename was refused, when it was silently relocated.
The Windows/3.11.0-3.11.3 rollback gap is stated plainly and left as a
follow-up, with the attempted fix and why it was reverted.

Also records that an earlier "ruff check . clean" in this session rested
on an exit code I had not actually checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Preserve fractional mtimes; baseline the content scan on the branch tip

Two findings from round sixteen.

`member.mtime` is a float when the archive is PAX, and `filter="data"`
restores the fraction. The fallback coerced it with `int()` at all three
write sites — regular files, directories and hardlink copies — so a
rollback's timestamps depended on the interpreter and two skills touched
within the same second collapsed to the same time. That is the ordering
`build_skill_nodes()` reads. Reverting the fix fails exactly the three
new parametrized cases, and only under `manual-fallback`.

The content scan baselined on `merge-base(origin/main, HEAD)`. Squashing
puts a new commit on the target, so a branch's own commits never become
ancestors of it and the merge base stays at the original divergence
point — making everything already merged look changed, materialized
against a baseline predating it, and re-reporting its accepted findings.
Now compares against the branch tip, which is the question the check
actually means. The range scan still uses the merge base, correct there.

Verified on a purpose-built squash-merged repo: the old baseline reports
`fixture.py` as introducing a credential that is already on main (exit
1); the new one sees one changed file and exits 0. bt2, uz, glrepo and
exotic all still exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round sixteen

Fractional mtimes truncated at all three write sites, and the content
scan's merge-base baseline going stale on squash-merged history. The
second retires a caveat I had recorded as unfixable branch hygiene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Materialize deleted files on the base side of the content scan

A deletion has no HEAD side, so the loop skipped it entirely — including
the base side. The comparison is a multiset over (rule, secret), so a
value that lived in a deleted file and resurfaced in a file that survived
had a base count of zero, and its HEAD occurrence read as newly
introduced. It had been in the base tree all along.

-M does not catch this: the whole file did not move, only a block of it,
which is exactly what a merge-conflict resolution produces.

Verified on a fixture where the credential lives in old.py on main and
reappears in keep.py only through a merge resolution, with old.py
deleted. The range scan is silent in both runs — it never attributes
merge content — so the content pass alone decides:

  OLD: 1 finding, 0 already present at the base, 1 new   -> exit 1
  NEW: 1 finding, 1 already present at the base, 0 new   -> exit 0

Discriminating, not merely quieter: the same fixture with a credential
that is NOT on main still exits 1. bt2, uz, glrepo, exotic and the
squash-history fixture are all unchanged.

COUNT is deliberately not incremented for deletions — it gates on there
being HEAD content worth scanning, and a delete-only change adds none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round seventeen

Deleted files were absent from the content baseline, so a credential
moved out of one by a merge resolution read as newly introduced. Also
records the full-suite comparison re-run on the current tree, which the
earlier figure predated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Restore the content scan on default-branch pushes

A regression I introduced in round 16. Before that rewrite a push fell
through to $BASE, which is the before-SHA, so the content pass ran. The
rewrite gated it on EXCLUDE, which is empty on the default branch by
construction — and the comment I wrote asserted the skip was "same as
before". It was not. A merge commit pushed straight to the default
branch stopped having its conflict resolution scanned, which is the
exact gap the content pass exists to close, reopened on the one branch
that ships.

The default branch now uses the push's before-SHA. It is both the right
baseline there and the only workable one: origin/main is where we
already are, so the tip would name no changed files. The stale-
divergence problem that motivates the tip elsewhere cannot arise, since
on the default branch the before-SHA is that branch's own previous tip.

Verified on a repo where a side branch is merged into main with the
credential appearing only in the resolution. `git log -p BEFORE..HEAD`
matches it zero times, so the range scan is structurally blind:

  round 17: range scan only, no content pass   -> exit 0   (missed)
  fixed:    1 finding, 0 at the base, 1 new    -> exit 1

Feature-branch pushes with no reachable default branch still skip,
deliberately — their before-SHA predates any merge of the default branch
and would name everything that merge carried along.

Matrix unchanged: bt2, uz, glrepo, exotic exit 1; squash-history and
deleted-source-move fixtures exit 0; deleted-source move with a value
not on main exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round eighteen

A regression I introduced in round sixteen: default-branch pushes
stopped getting the content pass, defended by a comment claiming the
skip was "same as before" when it was not. Also records the pattern —
four defects in this file, all checks passing on inputs never examined,
and this one survived because I reasoned in a comment instead of running
the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Split the content scan's two bases apart

One commit cannot answer both questions the content pass asks, and
trying to make it produced a false positive in each direction.

Round 16: the merge base alone re-reported squash-merged content,
because squashing means this branch's commits are never ancestors of
the target, so the merge base stays at the original divergence point.

Round 19: the target tip alone re-reports target-only changes. A branch
merely behind the target differs from it wherever the target moved on,
so if the target scrubbed a flagged value after divergence, this branch
still has it, the diff names that file, and it reports as introduced by
a push that never touched it.

So DIFF_BASE (merge base) selects which files — what this branch
changed, not what the target changed underneath it — and CONTENT_BASE
(target tip) supplies the exemption baseline, so a value the target
already ships is not reported again. On the default branch the two
collapse to the before-SHA, since there is no divergence to measure.

Squash-merged content now passes for a better reason than before: it is
in the file set and exempted by the tip, rather than excluded from the
set entirely.

Verified on a behind-main fixture where main scrubs a credential the
feature never touched:

  round 18: 2 files, legacy.py reported introduced  -> exit 1
  fixed:    1 file (app.py), 0 findings             -> exit 0

All six fixture classes hold: bt2/uz/glrepo/exotic exit 1; squash-history,
deleted-source-with-value-on-main and behind-main exit 0;
deleted-source-without and default-branch-merge-push exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round nineteen

The inverse of round sixteen: merge base alone re-reports squash-merged
content, target tip alone re-reports target-only changes. I had been
oscillating between two false positives. Split into DIFF_BASE for the
file set and CONTENT_BASE for the exemption baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Scan deleted files in their own tree

A file and a directory cannot share a name. Replacing the file `a` with
a package `a/` is an ordinary refactor and git reports it as `D a` plus
`A a/__init__.py`; the round-17 deletion handling wrote the deleted blob
to BASE_DIR/a, so the later `mkdir -p BASE_DIR/a` failed and `set -e`
aborted the job on a change containing no secret at all.

Reproduced before fixing:

  mkdir: cannot create directory '/tmp/.../base/config.py': File exists
  EXIT=1

Deletions now go to their own tree. They cannot collide with each other,
because one commit cannot have the same path as both a file and a
directory, so that tree is collision-free by construction. Relative
layout is preserved rather than flattened: .gitleaks.toml has
path-scoped allowlist entries, and flattening would apply them to one
side only, which is worse than the crash.

The comparator gains a repeatable --extra-base. Deleted findings are
still base-side findings and merge into the same exemption multiset;
six tests cover the merge, the multiplicity, and failing closed on an
unparseable extra report.

All seven fixture classes hold, including round 17's exemption, which
now arrives via the separate tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty

The round-seventeen deletion handling aborted the job on an ordinary
file-to-directory refactor. Deletions now scan in their own tree, which
is collision-free by construction; flattening was rejected because
.gitleaks.toml has path-scoped allowlists, and ordering deletions last
was rejected because it trades a crash for a silent false positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Use the umask for fallback dir modes; baseline renames from the destination

Two findings.

`filter="data"` clears a directory's archived mode and lets os.mkdir's
default 0777 meet the process umask. The fallback hard-coded 0755, which
matched only under umask 022 — where 0777 & ~022 happens to be 0755:

  umask 0022: stdlib 0755   fallback 0755
  umask 0002: stdlib 0775   fallback 0755
  umask 0000: stdlib 0777   fallback 0755

So a group-shared skills tree lost group write depending on the
interpreter's patch level. The round-12 test asserted a bare 0755 and so
could not see it; it now computes the expectation from the umask and
runs under four of them.

Separately, the base side looked up a rename's source in CONTENT_BASE,
but `src` comes from rename detection against DIFF_BASE, and since the
two-base split those are different revisions. A rename already
squash-merged is still reported as `R old new` against the merge base
while the target has only `new`, so the lookup found nothing, left an
empty baseline, and re-reported every existing finding in `new`.
Destination first now, source as the fallback.

Verified both rename directions: squash-merged rename exits 0 where it
previously reported a credential already on main, and a fresh rename the
target has not seen still resolves through `src`. All nine fixture
classes hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-one

Both findings were a constant that was really a measurement: 0755 was
one umask's answer written down as the contract, and `src` was one
revision's name used against another. Also records that round twelve's
conclusion was right but its generalisation was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Don't truncate hardlinks; exempt what the branch already had

Two findings.

P1: an existing hardlink at a destination leaf was opened with O_TRUNC,
so the outside file sharing that inode was overwritten in place.
O_NOFOLLOW rejects a symlink there and says nothing about a hardlink —
a hardlink is the file, not a reference to it. curator_backup preserves
skills/.hub, so the destination is not a clean tree. Reproduced on BOTH
paths, with the outside file left reading "PWNED" at nlink=2.

The fallback now unlinks before creating with O_EXCL. The stdlib path
has the same hole and is the one that actually runs, so hazards are
detached from the destination first: any leaf that is a symlink, or a
regular file with nlink > 1, is unlinked before extractall. That pass is
best-effort by design — it skips anything it cannot resolve rather than
raising, because _walk_dirs refuses to traverse a symlinked directory
and applying that to the stdlib path would newly break legitimate
symlinked skills, which agent/skill_utils.py supports.

P2: the content pass re-reported a finding an earlier commit on the
branch introduced, on every later push, because the target cannot exempt
what is not on the target. The range scan does not behave that way. The
previous branch head is now an extra exemption base, so only what this
push adds is new. Verified in all three directions: the introducing push
still fails, a later unrelated push goes green, and the PR event keeps
the branch-wide view — so the branch still cannot merge green.

Eleven fixture classes hold. 132 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-two

A P1 that destroyed data outside the skills tree: an existing hardlink
at a destination leaf was truncated in place, because O_NOFOLLOW says
nothing about hardlinks. Both extraction paths. Also records why the
stdlib pre-pass has to be best-effort rather than reusing _walk_dirs,
and why the push-scoping fix went through an extra exemption base rather
than narrowing DIFF_BASE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Close four holes, three of them in last round's fixes

Normalization mismatch (P1). The round-22 pre-pass used
_safe_member_parts, which rejects an absolute name, while filter="data"
strips the leading slash and extracts anyway. So "/.hub/x" was skipped as
"extraction will reject this" and then extracted straight through a
preserved hardlink. Reproduced: outside file PWNED, st_nlink still 2. The
pre-pass now mirrors the filter's own normalization.

Destructive pre-pass (P2). Detaching happened before the archive was
known good, and rollback() skips .hub during failure cleanup, so a
crafted archive could delete preserved state for nothing. Cancelling the
pass instead turned out to be worse — members extract in order, so an
appended invalid member disabled the pass and let the hardlink be
truncated anyway (measured). Unacceptable members now raise before
anything is touched, and the two checks are no stricter than `data`, so
no archive it would accept is refused. Link targets are deliberately not
checked: `data` allows a contained ../sibling.

Overlapping baselines (P1). base.json and prev.json are alternative
snapshots of the same paths, but --extra-base sums them, so two parents
each holding one copy exempted two — and a merge resolution duplicating a
credential passed. Added --alt-base, combined by maximum. A control test
asserts --extra-base on the same inputs still exits 0, so the new flag is
demonstrably doing the work.

StepFun endpoint (P2). Testing for "//" anywhere meant a bare host with a
doubled slash in its path was left unprefixed, parsed entirely as a path,
and yielded an empty hostname — reporting a live China endpoint as
international and offering to rewrite it to the .ai host. Now tests for a
scheme or a leading "//".

Eleven fixture classes hold; 150 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-three

Four findings, three of them holes in round twenty-two's fixes. Records
that my first answer to the destructive-pre-pass finding measured worse
than the bug, and names the common thread: a new mechanism bolted onto a
checked one without re-deriving what the existing invariant required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Five more: Windows hardlinks, link sources, timestamps, unions, new branches

P1 — the hardlink detach returned early without dir_fd, so on Windows the
hole stayed open on 3.11.4+ too: the filtered path reaches the same
pre-pass. Refusing there would disable rollback on Windows, so it falls
back to a path walk that lstats each component before descending. Racy
where the dir_fd route is not, and the docstring says so.

P1 — a hardlink's source was resolved against the extraction root and
copied whatever was there, so a crafted snapshot could name a preserved
file no member created (.hub/secret.txt) and pull it into the restored
tree; the stdlib path made a real hardlink, letting later writes mutate
state rollback excludes. The source must now be an earlier member. This
cannot reject a real snapshot: tarfile.add() only emits LNKTYPE for an
inode it has already archived.

P1 — an out-of-range PAX mtime raised OverflowError, which is not a
TarError, so rollback() skipped its extraction-failure recovery. The
stdlib path does the same and cannot be fixed from outside, so it is
rejected before extraction; the fallback additionally ignores the value.
NaN is not tested because tarfile cannot write one.

P2 — the two parent baselines were combined by maximum, which drops an
occurrence the parents hold at *different* paths. Summing was wrong in
the other direction, last round. Both reports are now unioned over
(rule, secret, file, line), keeping per-report multiplicity, so the same
occurrence seen twice collapses and disjoint ones survive.

P1 — a new branch arrives with an all-zero before-SHA and can publish
many commits; falling to HEAD~1 scanned only the last, missing a
credential added earlier in the same push. It now diverges from the
default branch, with the same exclusion and path scoping every other
range scan uses — omitting those reintroduced round 16.

Twelve fixture classes hold; 159 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-four

Five findings, four P1. Records that the Windows hardlink hole was open
on modern Python too, that the timestamp bug was never fallback-only,
that this was the third position on the baseline-merge question, and
that my first new-branch fix broke two fixtures plus a fault in my own
test harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Refuse redirecting symlink members; see junctions; reconcile parents by file

Three P1s, two of them in last round's work.

A symlink *member* can redirect a later member past the pre-pass: with
`a -> .hub` then `a/x`, nothing named `a` exists when the pass looks, and
extractall then creates the link and writes through it onto a preserved
hardlink. Reproduced — the outside inode came back holding the archived
bytes. This is round six's bypass in a new location: a pass over member
metadata cannot know where a member lands, because an earlier member
changes what a later path means. Members under a symlink member are now
refused, which is decidable from the members alone and cannot reject a
real snapshot, since tar does not archive content beneath a symlink.

The no-dir_fd walk added last round used os.path.islink(), which is False
for a Windows directory junction while isdir() follows it — so the walk
descended through one and unlinked an entry outside the tree. That is my
replacement performing the damage the early return had merely permitted.
Now lstat-based, rejecting any reparse tag, and stopping outright on
Windows if the tag attribute is somehow absent rather than guessing.

Parent reports were reconciled per (rule, secret, file, line). Including
the line looked more precise and was less correct: an inherited credential
routinely sits at different lines in the two parents, and counting those
as two occurrences inflated the exemption enough for a merge resolution
to slip a new copy past. Per file now.

164 tests, twelve fixture classes, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-five

Round six's bypass reappearing inside its own replacement; a Windows
junction defeating the walk I added last round, so my code did the
damage the early return had merely permitted; and line shifts breaking
parent identity. Also records the recommendation to do destination
safety as staging+swap in its own change rather than patch the pre-pass
a fourth time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Close the destination-hazard class instead of guarding it again

Two more P1s this round, both in the pre-pass, making six consecutive
rounds in which it produced one. Last round I wrote that the pre-pass is
being asked to predict where members land — the thing round six proved
cannot be done from member metadata — and that the fix was structural.
Patching a seventh time would be ignoring my own conclusion.

curator_backup already does most of the work: rollback() moves every
top-level entry aside except .hub and .curator_backups, and
snapshot_skills() excludes both, so no legitimate archive contains a
member under either and those two are all extraction can collide with.
Refusing such members therefore means extraction only ever writes paths
that do not exist yet — and an empty destination has no hardlink,
junction or symlink to abuse.

So the destination inspection is deleted, not extended. _detach_leaf and
the detachment loop are gone, including the unverifiable Windows
reparse-point handling. What remains is _validate_members, which decides
everything from the members alone: member type, path safety,
representable mtime, no member beneath a symlink member, and a hardlink
source that must be an earlier *regular file* member — "earlier member"
alone let `a -> .hub/x`, `b -> a`, then a regular `b` alias through.

All eight redirects found across rounds 22-26 now refuse on both paths
with the outside file intact, including the two reported today. Removing
the one refusal rule fails nine of them; the control confirms an ordinary
archive still extracts into a live destination.

160 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-six

The pre-pass is deleted rather than patched a seventh time. Records that
last round's recommendation was right about the problem and wrong about
the cost — I estimated a rollback redesign without reading rollback()
closely enough to see it already stages the tree aside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Check preserved names under both separators; validate directory mtimes

A member spelled `.hub\x` was one opaque POSIX component, so the
preserved-name refusal did not recognise `.hub` — while tarfile, which
builds its destination with os.path, treats the backslash as a separator
on Windows and extracts into the preserved tree after all. The new
empty-destination boundary was bypassable by spelling. The first
component is now taken under both readings, so the refusal does not
depend on which platform extracts.

Directory members short-circuited before the timestamp range check, and
`data` applies directory attributes *after* the members — so an
out-of-range mtime on a directory escaped as OverflowError from the very
end of extractall, past every other check and not a TarError, leaving
rollback() to skip recovery with the tree already staged. Validated
before the short-circuit now.

The third finding is not fixed, deliberately: it and round 25's are
mutually unsatisfiable. Both present parents holding one occurrence each
and a HEAD holding two; they differ only in whether HEAD's lines coincide
with the parents'. A rule using that coincidence satisfies both but
reports the moved secret of round 12, which is the comparator's founding
requirement. Demonstrated in the reply. The current rule errs toward
reporting, which is the correct direction for a secret scanner.

164 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs: record round twenty-seven and the declined trade-off

The preserved-name refusal was bypassable by spelling, and directory
members skipped the timestamp check. The third finding is unsatisfiable
alongside round twenty-five's — the only rule separating them reports the
moved secret of round twelve — so the reporting direction is kept and the
reasoning now lives in the comparator's docstring rather than only in a
PR comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Validate directory members; refuse duplicate member names

A directory member short-circuited the validator before the
preserved-name check, so `.hub/injected` was created inside the tree
rollback is supposed to leave alone, with rollback reporting success.
Nothing was written *through* it, which is why none of the redirect tests
caught it. Validated before the short-circuit now.

A name written twice invalidates anything already concluded about it:
regular `a`, then a symlink also named `a`, then hardlink `b -> a` — `a`
was a regular file when the hardlink was validated and a symlink when
extraction used it, so last round's source-type rule could not hold.
Rather than track provenance through replacement, duplicate destinations
are refused outright. tarfile.add() walks a tree once, so a real snapshot
never contains one; the existing real-archive test covers that.

170 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-eight

Two findings: the second directory short-circuit bug in two rounds, and
a duplicate member name invalidating the provenance last round's rule
depended on. Records why no existing test caught the first — they all
assert the outside file is untouched, and nothing is written through a
bare directory — and that the second was fixed by removing the
possibility rather than tracking it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Fold preserved names; refuse NUL paths; track symlinks under both separators

Three spellings walked past the preserved-name boundary or the symlink
ancestry check.

`.HUB/x` resolves onto the existing `.hub` on Windows and default macOS,
but the comparison was case-sensitive. Folded now — that can only
over-refuse, and only for an archive containing a differently-cased
`.hub`, which is not a skill name.

A member path containing an embedded NUL passed validation and then
raised `ValueError: embedded null byte` from the filesystem call.
rollback() catches only OSError and TarError, so it escaped recovery with
the tree already staged. Rejected during validation now. Note a short
name truncates at the NUL — it survives only through PAX's
length-prefixed long-path record, which is how the test builds it.

Symlink ancestry was tracked POSIX-only, so `a\link -> ..\.hub` followed
by `a\link\x` recorded a single component that is not a prefix of the
other single component, and the redirect went unseen where tarfile splits
on backslash. Both readings are tracked, matching what the preserved-name
check already did.

The fourth finding is declined on the same grounds as round 27's: the
rule that satisfies it — exempt only from parent paths that also appear
in HEAD — reports the moved secret of round 12. Demonstrated in the
reply.

178 tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round twenty-nine

Three spellings that reached the preserved tree by routes the boundary
did not recognise — including one where I applied "check both separator
readings" to one check and not to the one three lines away. The fourth
finding lands on the same wall as round twenty-seven's, and the log now
names the upstream fix: the workflow knows a delete-plus-add was a move,
where the comparator cannot infer it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Normalize once; separate refusal readings from host identity

Three of this round's five findings were the same defect for the third
round running: a normalization rule applied in one check and not in the
one beside it. NUL checked on member.name but not on linkname; case
folded for the preserved-name check but not for symlink ancestry;
directory members short-circuiting past the ancestry check, the third
member kind to skip a check that was never type-specific.

So the shape is fixed rather than the spots. _canonical_readings()
computes every reading once — both separators, casefolded — and every
refusal rule compares against it. No member type short-circuits: each
rule now runs for directories too.

Writing that surfaced a second distinction the rewrite initially got
wrong, caught by an existing test. Refusal rules should consider every
reading, because over-refusing costs nothing. Identity rules must not:
judging duplicates across both readings made `demo/a\b` and `demo/a/b`
collide, and on POSIX those are two legitimate files. _host_identity()
uses the host's separator and os.path.normcase, which lowercases on
Windows and is the identity on POSIX. Duplicates and hardlink sources
use it; refusals use the readings.

Workflow: a merge whose resolution restores a file to its merge-base blob
leaves no entry in the DIFF_BASE diff, so the content pass had nothing to
materialize. Each parent's diff is now unioned into the file set. And a
default-branch rewrite has genuinely nothing to diff against — the old
tip is gone and there is no other branch to take a merge base from — so
the one-commit fallback now says so with a warning instead of leaving the
gap implicit.

186 tests, twelve fixture classes, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round thirty

Three findings were the same normalization defect for the third round
running, so the shape is fixed rather than the spots. Records that the
rewrite initially regressed round twenty-one — refusal and identity
comparisons need opposite treatment — plus the one fix this round with no
reproduction behind it and the one finding left unfixed with a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Fold Unicode spellings for refusal; raise the floor to 3.11.4

Two findings, and the second is the more serious.

Unicode normalization. macOS is normalization-*insensitive*: a symlink
member named `é` composed and a later member named `é` decomposed are one
file there, so the second resolves through the first while the two
component tuples compare as different strings. Casefolding alone is not
filesystem identity. `_caseless` now applies one NFC pass before the fold,
in that order — NFC makes canonically-equivalent spellings byte-identical,
so the casefold that follows cannot diverge; folding first would not have
that property, because casefolding emits an unnormalized result for 26 code
points. Verified exhaustively over the code-point range.

It is applied on the refusal side only, which is the same split the last
round arrived at for separators and case: refusal rules consider every
reading, because over-refusing costs nothing; identity rules must not, and
`_host_identity` deliberately leaves both spellings distinct — Linux stores
them as two files and folding them would call a legitimate archive a
duplicate-destination collision. Both directions are pinned by tests.

requires-python floor. The `<3.14` cap stayed; the floor moves 3.11 ->
3.11.4, where tarfile's `data` filter landed. Below it the manual path
runs, and that path needs `dir_fd` to refuse traversing a symlink already
in the destination — which Windows does not have. The combination refuses
to extract at all, so a curator rollback there fails before its first
member: the mitigation destroying the feature it protects. Excluding the
combination is the honest fix, and no real install is affected — CI's
`python-version: '3.11'` and the Windows installer both resolve to the
newest 3.11.x. Declined in round fifteen as unverifiable because the
sandbox uv could not parse the lockfile schema; a newer uv can, and the
regenerated lock is one line.

Positive control: reverting `_caseless` to a bare casefold fails exactly
the two new refusal cases (both extraction paths) and nothing else.
190 tests across the four affected files; ruff clean; `uv lock --check`
passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round thirty-one

Records the Unicode-normalization bypass as a third axis alongside case and
separators, the sweep that showed the second reading was redundant and got
it dropped, and the round-fifteen refusal reversed now that a newer uv can
regenerate the lockfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Stage base blobs so a failed lookup cannot blank a populated baseline

Redirection truncates its target before `git show` runs, so a lookup that
fails leaves an empty file rather than the previous contents. That is only
reachable because one destination can arrive twice with a different `src`:
the parent loop emits `R old new` against the parent that predates the
rename and `M new` against one that already has it. The first record reads
the baseline from `old` and fills it; the second looks for `new` in
CONTENT_BASE, does not find it, and blanks what the first wrote. An
unchanged credential carried through the rename was then reported as newly
introduced — the diff-only contract, violated in the reporting direction.

The comment claiming duplicate records are harmless was right about the
bytes and wrong about the failures. Both base trees now stage through a
temp file and install on success, so a failed lookup is a no-op instead of
a truncation.

Reproduction, as a thirteenth workflow fixture (`/tmp/mk_rendup.sh`):
origin/main keeps `old.py` with a credential; a feature branch renames it
to `new.py`; a sibling branch edits a different line; the merge differs
from both parents, so the record stream is `R old.py new.py` followed by
`M new.py` twice. Before: "0 already present at the base, 1 new", exit 1.
After: "1 already present at the base, 0 new", exit 0. Re-running all
thirteen fixtures against the unmodified workflow and the fixed one
changes exactly that line and nothing else.

Also records what COUNT actually counts — records, not distinct paths — so
the log line is not read as a file count. Both uses are `-gt 0` gates, so
the over-count changes no behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* docs(system-log): record round thirty-two and itemize the red checks

Records the truncation defect as the failure half of a comment I wrote two
rounds earlier, the fixture that reproduces it, and the thirteen-fixture
control showing exactly one line moved. Also itemizes every red check on the
branch with its cause, including the electron pin mismatch that #155
introduced on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

* Fold hardlink provenance: invalidation is a refusal question

`os.path.normcase` is the identity function on macOS while the default
filesystem there is case-insensitive, so `demo/A` and `demo/a` name one
entry that host-keyed provenance saw as two. A regular `demo/A`, a symlink
`demo/a -> ../.hub/x`, then a hardlink to `demo/A`: the symli…
github-actions Bot pushed a commit that referenced this pull request Aug 11, 2026
…ned (#171)

`apps/desktop/package.json` declared `electron: 41.10.3` while
`build.electronVersion` still read `40.10.2`. Those fields do different jobs:
the dependency plus the lockfile decide what `npm ci` installs — what tests run
against and what `electron .` uses in dev — while `build.electronVersion`
decides which Electron dist electron-builder packages. The shipped app was
therefore a different Electron from the tested one, so code using a 41-only API
would pass CI and fail in the packaged build. `desktop-electron-pin.test.ts`
exists to catch this and was red on main.

There was a fourth reference no test covered: root `package.json` `allowScripts`
is keyed by `name@version` and still said `electron@40.10.2`. Electron's
postinstall is what fetches the binary, so a stale key means the allowlist
silently stops covering it. Nothing in this repo reads the field — it appears
only as data in the root and website manifests — which is exactly why the drift
went unnoticed, and is worth a look from whoever owns the external consumer.

Aligned up, to what is actually installed. Aligning down would mean reverting
the dependency and regenerating the lockfile, which is larger and could not be
resolved offline here; up is also the safer direction on the merits, since it
ships what is tested.

A fourth assertion now pins the allowScripts key so the next bump cannot leave
it behind. It returns early when there is no electron key at all, since not
pinning there is a choice rather than drift.

Verified without node_modules — installing the monorepo would drag in the known
react/react-dom skew, and this test reads only two JSON files, so its assertions
were executed directly in Node against the real files. Before: 2 pass, 1 fail.
After: 4 pass. Positive control: reverting only the allowScripts line fails only
the new assertion.

Corrects a claim I published in #156 and the 2026-08-08 log: I attributed this
mismatch to #155, which in fact bumped only dompurify and js-yaml. `git log`
pointed there because this clone is shallow and that commit is the graft
boundary, so the whole file reads as added in it.


Claude-Session: https://claude.ai/code/session_012689txgT12g2hjRczcUZi8

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants