chore(deps): bump the npm_and_yarn group across 3 directories with 2 updates - #155
Merged
github-actions[bot] merged 1 commit intoAug 8, 2026
Conversation
…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>
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
1 task
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>
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.
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
dompurifyfrom 3.4.12 to 3.4.13Release notes
Sourced from dompurify's releases.
Commits
3067f77release: 3.4.13 (#1562)Updates
js-yamlfrom 4.3.0 to 4.3.1Changelog
Sourced from js-yaml's changelog.
Commits
86e91b84.3.1 releasedc3cc4b0Backport quadratic complexity fix for !!omapUpdates
dompurifyfrom 3.4.12 to 3.4.13Release notes
Sourced from dompurify's releases.
Commits
3067f77release: 3.4.13 (#1562)Updates
dompurifyfrom 3.4.12 to 3.4.13Release notes
Sourced from dompurify's releases.
Commits
3067f77release: 3.4.13 (#1562)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 rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore <dependency name> major versionwill 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 versionwill 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 conditionsYou can disable automated security fix PRs for this repo from the Security Alerts page.